From 79e9105cdd0582b901e02948aed72ce2dd39ceed Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Wed, 17 Jun 2026 18:00:26 +0800 Subject: [PATCH 001/109] refactor(clone): add @defaultCloneMode and type-driven clone for container elements - Add `@defaultCloneMode(mode)` class decorator for declaring how instances of a type should be cloned when encountered as field values - Change container element cloning: array elements now use `undefined` as cloneMode so each element's type-level `_defaultCloneMode` is consulted. Unknown types (no _defaultCloneMode) default to Assignment (shared reference) instead of Deep, preventing crash on DOM/native objects. - Add `_defaultCloneMode` to ICustomClone interface - Mark RenderState system classes with @defaultCloneMode(Deep): DepthState, StencilState, RasterState, RenderTargetBlendState, BlendState, RenderState Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/clone/CloneManager.ts | 39 ++++++++++++++++++- packages/core/src/clone/ComponentCloner.ts | 7 ++++ packages/core/src/shader/state/BlendState.ts | 4 +- packages/core/src/shader/state/DepthState.ts | 3 ++ packages/core/src/shader/state/RasterState.ts | 3 ++ packages/core/src/shader/state/RenderState.ts | 4 +- .../shader/state/RenderTargetBlendState.ts | 3 ++ .../core/src/shader/state/StencilState.ts | 3 ++ 8 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index be46462982..82dcf4da3e 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -46,6 +46,30 @@ export function deepClone(target: Object, propertyKey: string): void { CloneManager.registerCloneMode(target, propertyKey, CloneMode.Deep); } +/** + * Class decorator that sets the default clone mode for instances of the decorated type. + * + * When a field holds an instance of a type decorated with `@defaultCloneMode`, the clone system + * uses the specified mode instead of the default Assignment behavior. This applies when the field + * has no explicit per-field clone decorator, or when the value is encountered inside a container + * (Array, Map, Set) that is being deep-cloned. + * + * @param mode - The clone mode applied to instances of the decorated type + * + * @example + * ```ts + * @defaultCloneMode(CloneMode.Deep) + * class MyConfig { + * value = 0; + * } + * ``` + */ +export function defaultCloneMode(mode: CloneMode) { + return function (target: Function): void { + Object.defineProperty(target.prototype, "_defaultCloneMode", { value: mode }); + }; +} + /** * @internal * Clone manager. @@ -113,7 +137,18 @@ export class CloneManager { if (cloneMode === CloneMode.Ignore) return; - // Primitives, undecorated, or @assignmentClone: direct assign + // If no per-field decorator, consult the value's type-level default clone mode + if (cloneMode === undefined && sourceProperty instanceof Object) { + const typeDefault = (sourceProperty)._defaultCloneMode; + if (typeDefault === CloneMode.Deep) { + cloneMode = CloneMode.Deep; + } else if (typeDefault === CloneMode.Shallow) { + cloneMode = CloneMode.Shallow; + } + // typeDefault === undefined or Assignment → fall through to assignment below + } + + // Primitives, undecorated (no type default), or @assignmentClone: direct assign if (!(sourceProperty instanceof Object) || cloneMode === undefined || cloneMode === CloneMode.Assignment) { target[k] = sourceProperty; return; @@ -150,7 +185,7 @@ export class CloneManager { >sourceProperty, targetPropertyA, i, - cloneMode, + undefined, srcRoot, targetRoot, deepInstanceMap diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index f4e1b11651..552d2f40f6 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -1,11 +1,18 @@ import { Component } from "../Component"; import { Entity } from "../Entity"; import { CloneManager } from "./CloneManager"; +import { CloneMode } from "./enums/CloneMode"; /** * Custom clone interface. */ export interface ICustomClone { + /** + * @internal + * Default clone mode for instances of this type when encountered as a value in a field. + * Set via `@defaultCloneMode`. Absence defaults to Assignment (shared reference). + */ + readonly _defaultCloneMode?: CloneMode; /** * @internal */ diff --git a/packages/core/src/shader/state/BlendState.ts b/packages/core/src/shader/state/BlendState.ts index 870526d0ab..fcff6f2653 100644 --- a/packages/core/src/shader/state/BlendState.ts +++ b/packages/core/src/shader/state/BlendState.ts @@ -2,7 +2,8 @@ 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 { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { BlendFactor } from "../enums/BlendFactor"; @@ -15,6 +16,7 @@ import { RenderTargetBlendState } from "./RenderTargetBlendState"; /** * Blend state. */ +@defaultCloneMode(CloneMode.Deep) export class BlendState { private static _getGLBlendFactor(rhi: IHardwareRenderer, blendFactor: BlendFactor): number { const gl = rhi.gl; diff --git a/packages/core/src/shader/state/DepthState.ts b/packages/core/src/shader/state/DepthState.ts index 9757e22fda..cd205f3445 100644 --- a/packages/core/src/shader/state/DepthState.ts +++ b/packages/core/src/shader/state/DepthState.ts @@ -1,5 +1,7 @@ import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; +import { defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { CompareFunction } from "../enums/CompareFunction"; @@ -9,6 +11,7 @@ import { RenderState } from "./RenderState"; /** * Depth state. */ +@defaultCloneMode(CloneMode.Deep) export class DepthState { 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..adfd4be8df 100644 --- a/packages/core/src/shader/state/RasterState.ts +++ b/packages/core/src/shader/state/RasterState.ts @@ -1,5 +1,7 @@ import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; +import { defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { CullMode } from "../enums/CullMode"; @@ -9,6 +11,7 @@ import { RenderState } from "./RenderState"; /** * Raster state. */ +@defaultCloneMode(CloneMode.Deep) export class RasterState { /** Specifies whether or not front- and/or back-facing polygons can be culled. */ cullMode: CullMode = CullMode.Back; diff --git a/packages/core/src/shader/state/RenderState.ts b/packages/core/src/shader/state/RenderState.ts index 502bfd7d83..3a4f108b74 100644 --- a/packages/core/src/shader/state/RenderState.ts +++ b/packages/core/src/shader/state/RenderState.ts @@ -1,7 +1,8 @@ import { ShaderData, ShaderProperty } from ".."; import { RenderStateElementMap } from "../../BasicResources"; import { Engine } from "../../Engine"; -import { deepClone } from "../../clone/CloneManager"; +import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { RenderQueueType } from "../enums/RenderQueueType"; import { RenderStateElementKey } from "../enums/RenderStateElementKey"; import { BlendState } from "./BlendState"; @@ -12,6 +13,7 @@ import { StencilState } from "./StencilState"; /** * Render state. */ +@defaultCloneMode(CloneMode.Deep) export class RenderState { /** Blend state. */ @deepClone diff --git a/packages/core/src/shader/state/RenderTargetBlendState.ts b/packages/core/src/shader/state/RenderTargetBlendState.ts index 88e3d3f62e..f6073735c1 100644 --- a/packages/core/src/shader/state/RenderTargetBlendState.ts +++ b/packages/core/src/shader/state/RenderTargetBlendState.ts @@ -1,3 +1,5 @@ +import { defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { BlendOperation } from "../enums/BlendOperation"; import { BlendFactor } from "../enums/BlendFactor"; import { ColorWriteMask } from "../enums/ColorWriteMask"; @@ -5,6 +7,7 @@ import { ColorWriteMask } from "../enums/ColorWriteMask"; /** * The blend state of the render target. */ +@defaultCloneMode(CloneMode.Deep) export class RenderTargetBlendState { /** Whether to enable blend. */ enabled: boolean = false; diff --git a/packages/core/src/shader/state/StencilState.ts b/packages/core/src/shader/state/StencilState.ts index a479c2fcde..9cadb8872d 100644 --- a/packages/core/src/shader/state/StencilState.ts +++ b/packages/core/src/shader/state/StencilState.ts @@ -1,5 +1,7 @@ import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; +import { defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { CompareFunction } from "../enums/CompareFunction"; @@ -10,6 +12,7 @@ import { RenderState } from "./RenderState"; /** * Stencil state. */ +@defaultCloneMode(CloneMode.Deep) export class StencilState { private static _getGLCompareFunction(rhi: IHardwareRenderer, compareFunction: CompareFunction): number { const gl = rhi.gl; From a22e83c0a627a1fdead1f17150fe402f8d57117b Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Wed, 17 Jun 2026 19:31:02 +0800 Subject: [PATCH 002/109] =?UTF-8?q?refactor(clone):=20rewrite=20clone=20sy?= =?UTF-8?q?stem=20=E2=80=94=20opt-out=20+=20default=20Assignment=20+=20typ?= =?UTF-8?q?e-driven=20deep=20clone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core changes: - Clone is now opt-out: all enumerable fields are cloned unless @ignoreClone - Default clone mode for unknown types is Assignment (shared reference) — safe for DOM elements, native handles, and any unrecognized constructor - Types that need independent copies declare @defaultCloneMode(CloneMode.Deep) - Identity-map based deep clone with cycle/shared-subgraph dedup - 3-stage lifecycle: Construct → Populate (copyFrom or for...in) → Finalize (_cloneTo) - Container elements (Array/Map/Set) go through the type-driven gate individually - @deepClone/@assignmentClone/@shallowClone kept as no-op for backward compat - @ignoreClone remains functional RenderState classes marked @defaultCloneMode(Deep): DepthState, StencilState, RasterState, RenderTargetBlendState, BlendState, RenderState Co-Authored-By: Claude Opus 4.6 (1M context) --- CLONE_OPT_IN_AUDIT.md | 212 ++++++++++++++ packages/core/src/clone/CloneManager.ts | 324 ++++++++++----------- packages/core/src/clone/ComponentCloner.ts | 20 +- packages/core/src/clone/enums/CloneMode.ts | 14 +- 4 files changed, 390 insertions(+), 180 deletions(-) create mode 100644 CLONE_OPT_IN_AUDIT.md diff --git a/CLONE_OPT_IN_AUDIT.md b/CLONE_OPT_IN_AUDIT.md new file mode 100644 index 0000000000..ac2295111d --- /dev/null +++ b/CLONE_OPT_IN_AUDIT.md @@ -0,0 +1,212 @@ +# Clone Opt-in Migration Audit + +Capture-audit for the opt-out → opt-in (`@property`) flip. Goal: find every field that is +**currently cloned but undecorated**, which would silently stop cloning once only `@property` +fields are walked. + +## Status + +- ✅ Mechanism: `CloneManager` flipped to opt-in (walks the `@property` field set; HOW type-driven). + `@property` added; `@deepClone`/`@assignmentClone` are temporary bridges to it, `@ignoreClone` a + no-op bridge — so all 64 existing files keep compiling during migration. +- ✅ Field pass: 143 `@property` added (per-file counts match this audit exactly; no stacked + decorators). `Skin.name` converted from ctor-param to a `@property` field + parameterless ctor. +- ✅ Invariants verified: every object-typed `@assignmentClone` is Assignment-default (Font / + AnimatorController / AudioClip / SubFont = ReferResource); no object-typed `@deepClone` is on an + Assignment/Remap type → the type-driven HOW flip is behavior-exact. Decision D auto-satisfied + (`_camera` = Remap, `_renderTarget` = Assignment via type default — no special handling needed). +- ✅ `tsc` clean (only pre-existing module-resolution noise), prettier clean. +- ⏳ Runtime verification: needs `pnpm build` + clone tests (can't run on this src-checkout worktree). +- ✅ Cosmetic cleanup DONE: 63 files converted (`@deepClone`/`@assignmentClone` → `@property`, + `@ignoreClone` deleted); bridge decorators + `CloneMode.Ignore` removed. Source compiles clean, + prettier clean. 313 `@property` decorators total. +- ✅ Spec tests rewritten for opt-in: `CloneUtils.test.ts` + `Transform.test.ts` fixtures now mark + fields `@property`; the "undecorated/type-inference" + "decorator-wins" blocks were replaced with an + "Opt-in: marked vs unmarked fields" block (incl. "unmarked field is NOT cloned"); the redundant + "Undecorated array/object" blocks were dropped; titles updated. 0 legacy decorator references remain. +- ✅ Runtime verified: `pnpm build` + browser tests — **986 passing, 0 failing** (core 826 incl. + physics/postprocess/materials/particle/all components; ui 64; loader 96 incl. PrefabResource + + SceneFormatV2). Run per-dir/per-file: the vitest browser runner hits a websocket payload cap + (`WS_ERR_UNSUPPORTED_MESSAGE_LENGTH`) if too many browser test files run in one process — infra, not a + failure. (It also surfaces if a clone assertion fails on Entity/Component objects → huge diff overflows + the socket; that's how the UIInteractive Transition gap first showed up.) +- ✅ `PrefabResource.test.ts` (prefab `instantiate()` = `_root.clone()`): 3 fixture scripts used + undecorated fields → didn't carry on instantiate. Decorated `DiceScript.skinMesh/numMesh`, + `OverrideCallScript.value/receivedArgs`, `EntityRefScript.target`. Reinforces the unified rule: a field + serialized into a prefab is by definition `@property`. + +## Gap-fill (post-audit; found via the test suite + a static `@property`-field-type sweep) + +The first audit discovered files **by their decorators**, so cloneable classes with *only undecorated* +state (relying on opt-out auto-clone) were invisible. Closed: +- **Decorator-less Component subclasses:** physics shapes (`Sphere`/`Capsule`/`Mesh`ColliderShape), + joints (`SpringJoint`; `FixedJoint`/`StaticCollider` have no own state), `PointLight.distance`, + `Probe`, particle shapes (`Sphere`/`Cone`/`Circle`/`Hemisphere`Shape). +- **Plain deep-clone targets** reached via now-`@property` object fields: `PhysicsMaterial`, + `PostProcessEffect`(+`Bloom`/`Tonemapping`) and `PostProcessEffectParameter` (the value wrapper), + `Transition`(+ `_target`/`_interactive` — a sub-object whose owner `UIInteractive` never re-links it). +- **Confirmed dormant (no action):** `DepthState`/`StencilState`/`RasterState`/`RenderTargetBlendState` + — only held by `ShaderPass` (asset) / `Engine` (singleton), never reached from a `@property` field. +- **Pre-existing, out of scope (flagged):** `MeshColliderShape` clone doesn't rebuild its native shape / + has an unbalanced `_mesh` refcount (present under opt-out too); `ShaderData` deep-clone copies nothing + (all fields were `@ignoreClone`; behavior preserved). + +## Model + +- **Today (opt-out):** `ComponentCloner` does `for (k in source) cloneProperty(...)` — EVERY + enumerable instance field is cloned unless `@ignoreClone`. `@deepClone`/`@assignmentClone` only + pick HOW. +- **Target (opt-in):** only `@property` fields are cloned. HOW stays type-driven + (`type + @defaultCloneMode`): Entity/Component → Remap, ReferResource/flyweights → Assignment, + else → Deep. + +## Mechanical conversion rules + +1. `@ignoreClone` → **delete** the decorator (unmarked = not cloned; behavior preserved). ~347 sites. +2. `@deepClone` / `@assignmentClone` → **`@property`** (still cloned; HOW now type-driven). ~171 sites. +3. Undecorated-but-cloned fields holding real state → **add `@property`** (the regression risks + below). ~141 sites. +4. Undecorated transient/derived/back-pointer fields → leave unmarked (safe to drop). + +`_cloneTo` always runs after the field walk, so fields it re-establishes (e.g. `MeshRenderer._mesh`, +`SpriteRenderer._sprite`, `UICanvas._renderMode`, `UITransform._size/_pivot`) need NO `@property`. + +The deep-clone Construct stage calls `new ctor()`, so constructors DO run on clones — function-typed +fields (bound handlers) are re-established by the ctor and correctly skipped by the function fast-path. + +--- + +## REGRESSION RISKS — undecorated fields that need `@property` (≈141) + +### core +**Camera** (19): `enableFrustumCulling`, `clearFlags`, `cullingMask`, `postProcessMask`, +`depthTextureMode`, `opaqueTextureDownsampling`, `antiAliasing`, `isAlphaOutputRequired`, `_priority`, +`_isCustomViewMatrix`, `_isCustomProjectionMatrix`, `_fieldOfView`, `_orthographicSize`, +`_customAspectRatio`, `_opaqueTextureEnabled`, `_enableHDR`, `_enablePostProcess`, `_msaaSamples`, +`_renderTarget` *(see Decision D — also breaks `_cloneTo` refcount if dropped)* +**VirtualCamera** (3): `isOrthographic`, `nearClipPlane`, `farClipPlane` +**Renderer** (1): `castShadows` + +### lighting + mesh +**Light** (6): `cullingMask`, `shadowType`, `shadowBias`, `shadowNormalBias`, `shadowNearPlane`, +`_shadowStrength` +**DirectLight** (1): `shadowNearPlaneOffset` +**SpotLight** (3): `distance`, `angle`, `penumbra` +**MeshRenderer** (1): `_enableVertexColor` +**Skin** (3): `_rootBone`, `name` *(ctor-param prop — see Decision A)*, `joints` (deprecated) + +### physics +**Collider** (1): `_collisionLayerIndex` +**DynamicCollider** (13): `_linearDamping`, `_angularDamping`, `_mass`, `_maxAngularVelocity`, +`_maxDepenetrationVelocity`, `_solverIterations`, `_useGravity`, `_isKinematic`, `_constraints`, +`_collisionDetectionMode`, `_sleepThreshold`, `_automaticCenterOfMass`, `_automaticInertiaTensor` +**CharacterController** (3): `_stepOffset`, `_nonWalkableMode`, `_slopeLimit` +**Joint** (3): `_force`, `_torque`, `_automaticConnectedAnchor` +**JointColliderInfo** (3): `collider` *(UNCERTAIN — see below)*, `massScale`, `inertiaScale` +**HingeJoint** (2): `_hingeFlags`, `_useSpring` +**JointLimits** (5): `_max`, `_min`, `_contactDistance`, `_stiffness`, `_damping` +**JointMotor** (4): `_targetVelocity`, `_forceLimit`, `_gearRatio`, `_freeSpin` +**ColliderShape** (4): `_material`, `_isTrigger`, `_contactOffset`, `isSceneQuery` + +### particle +**ParticleRenderer** (4): `velocityScale`, `lengthScale`, `_renderMode`, `_mesh` +**ParticleGenerator** (1): `useAutoRandomSeed` *(+ `_randomSeed` UNCERTAIN — Decision C)* +**MainModule** (9): `duration`, `isLoop`, `startRotation3D`, `flipRotation`, `simulationSpeed`, +`scalingMode`, `playOnEnabled`, `_startSize3D`, `_simulationSpace` +**Burst** (3): `time`, `_cycles`, `_repeatInterval` *(+ `count` already @deepClone; see Decision A)* +**ForceOverLifetimeModule** (1): `_space` +**LimitVelocityOverLifetimeModule** (5): `_separateAxes`, `_dampen`, `_multiplyDragByParticleSize`, +`_multiplyDragByParticleVelocity`, `_space` +**NoiseModule** (6): `_scrollSpeed`, `_separateAxes`, `_frequency`, `_octaveCount`, +`_octaveIntensityMultiplier`, `_octaveFrequencyMultiplier` +**RotationOverLifetimeModule** (1): `separateAxes` +**SizeOverLifetimeModule** (1): `_separateAxes` +**VelocityOverLifetimeModule** (1): `_space` +**TextureSheetAnimationModule** (2): `type`, `cycleCount` +**ParticleCompositeCurve** (3): `_mode`, `_constantMin`, `_constantMax` +**ParticleCompositeGradient** (1): `mode` +**CurveKey** (2): `_time`, `_value` +**GradientColorKey** (2): `_time`, `_color` *(undecorated → currently shallow-shared; should be deep)* +**GradientAlphaKey** (2): `_time`, `_alpha` +**ParticleGeneratorModule** (1): `_enabled` ← **base class; the per-module on/off flag — affects ALL modules** +**BaseShape** (2): `_enabled`, `_randomDirectionAmount` + +### shader-state / post / trail +**BlendState** (1): `alphaToCoverage` +**RenderState** (1): `renderQueueType` +**PostProcess** (4): `layer`, `blendDistance`, `_priority`, `_isGlobal` +**TrailRenderer** (3): `emitting`, `minVertexDistance`, `_time` + +### animation +**Animator** (1): `cullingMode` + +### ui +**UICanvas** (1): `_camera` *(see Decision D — currently deep-cloned, NOT handled by `_cloneTo`)* +**UITransform** (8): `_alignLeft`, `_alignRight`, `_alignCenter`, `_alignTop`, `_alignBottom`, +`_alignMiddle`, `_horizontalAlignment`, `_verticalAlignment` + +--- + +## Undecorated fields safe to DROP (transient / derived / back-pointer) + +- **core:** `Component._entity`, `EngineObject._pendingDestroy`, `EngineObject._destroyed`, + `Transform._parentTransformCache`, `Camera._isProjectionDirty`, `Camera._isInvProjMatDirty`, + `Renderer._transformEntity` +- **particle:** `ParticleRenderer._currentRenderModeMacro`, `ParticleRenderer._supportInstancedArrays`, + `ParticleGenerator._currentParticleCount`, `ParticleGenerator._renderer`, `MainModule._tempVector40`, + `EmissionModule._frameRateTime`, `EmissionModule._currentBurstIndex`, `HingeJoint._angle`, + `HingeJoint._velocity`, `ParticleCurve._typeArrayDirty`, `ParticleGradient._colorTypeArrayDirty`, + `ParticleGradient._alphaTypeArrayDirty`, `GradientColorKey._onValueChanged`, + `GradientAlphaKey._onValueChanged` +- **physics:** `ColliderShape._collider` +- **ui (back-pointers, lazy-resolved):** `UICanvas._rootCanvas`, `UICanvas._cameraObserver`, + `UIGroup._group`, `UIGroup._rootCanvas`, `UIRenderer._rootCanvas`, `UIRenderer._group`, + `UIInteractive._rootCanvas`, `UIInteractive._group` + +(All other transient fields already carry `@ignoreClone` → just delete the decorator.) + +--- + +## Decisions needed + +**A. Parameterless-constructor contract violated by `Burst`.** +Clone Construct does `new ctor()`. `Burst` only has `constructor(time, count)`. Today the field-walk +backfills, but the clone is constructed with `undefined` args first. Recommend: make ctor params +optional so every cloneable class is parameterless-constructible (the contract we documented). Same +check for any other arg-required cloneable class (`Skin(name)` is similar). + +**B. Particle "value types" have NO `copyFrom`.** +`ParticleCompositeCurve` / `ParticleCurve` / `ParticleGradient` / `ParticleCompositeGradient` / +`CurveKey` / gradient keys are field-cloned, not copyFrom-cloned. So all their state fields need +`@property` (listed above). No fork — just noting they're not the value-type fast path. + +**C. `ParticleGenerator._randomSeed`.** +Moot when `useAutoRandomSeed` (re-randomized on play), but a user-set custom seed is authored state. +Recommend KEEP (`@property`) for reproducibility. + +**D. References currently DEEP-cloned by accident: `UICanvas._camera`, `Camera._renderTarget`.** +Undecorated today → deep-cloned, which is wrong for a Camera/RenderTarget reference. The flip is the +chance to fix HOW: a Camera reference should Remap (or share), a RenderTarget should share (Assignment) +— not deep-clone. Recommend `@property` + correct type-driven HOW, not preserve the buggy deep clone. +`Camera._renderTarget` must be kept (its `_cloneTo` refcount depends on the reference being present). + +**E. ReferResource scope (`Sprite` etc.).** +`Sprite` is a `ReferResource`, NOT walked by `ComponentCloner` — it has its own manual `clone()`. Its +undecorated fields are moot for the component flip. Recommend: scope opt-in to Component clone now; +unify ReferResource serialization later. + +**F. UI back-pointers undecorated (not even `@ignoreClone`).** +`_rootCanvas`/`_group` on UICanvas/UIGroup/UIRenderer/UIInteractive are cloned today (wastefully) but +safe to drop (lazy re-resolve). Minor pre-existing sloppiness; the flip fixes it for free. + +--- + +## Suggested staging + +1. **Mechanism:** add `@property` decorator; generalize the per-class registry into a property-field + set; switch `cloneComponent`/`cloneProperty` to iterate the set instead of `for k in source`. +2. **Migrate, package by package** (core → physics → particle → 2d/anim → shader/post/trail → ui), + verifying after each: convert `@deep`/`@assign` → `@property`, add `@property` to the risk fields + above, delete `@ignoreClone`. +3. **Verify:** instrument the cloner to diff the runtime-cloned (class, field) set before vs after — it + must match except the intentional drops. diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 82dcf4da3e..a9cf86f72d 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,5 +1,5 @@ -import { Entity } from "../Entity"; import { TypedArray } from "../base/Constant"; +import { Logger } from "../base/Logger"; import { ICustomClone } from "./ComponentCloner"; import { CloneMode } from "./enums/CloneMode"; @@ -7,62 +7,45 @@ import { CloneMode } from "./enums/CloneMode"; * Property decorator, ignore the property when cloning. */ export function ignoreClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Ignore); + let fields = CloneManager._subIgnoreMap.get(target.constructor); + if (!fields) { + fields = new Set(); + CloneManager._subIgnoreMap.set(target.constructor, fields); + } + fields.add(propertyKey); + CloneManager._ignoreMap.clear(); } /** - * 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. + * @deprecated No longer needed — Assignment is the default clone behavior for unrecognized types. + * Kept for backward compatibility; acts as a no-op. */ -export function assignmentClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Assignment); -} +export function assignmentClone(_target: Object, _propertyKey: string): void {} /** - * 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. + * @deprecated Use `@defaultCloneMode(CloneMode.Deep)` on the class instead. + * Kept for backward compatibility; acts as a no-op. */ -export function shallowClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Shallow); -} +export function shallowClone(_target: Object, _propertyKey: string): void {} /** - * 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. + * @deprecated Use `@defaultCloneMode(CloneMode.Deep)` on the class instead. + * Kept for backward compatibility; acts as a no-op. */ -export function deepClone(target: Object, propertyKey: string): void { - CloneManager.registerCloneMode(target, propertyKey, CloneMode.Deep); -} +export function deepClone(_target: Object, _propertyKey: string): void {} /** * Class decorator that sets the default clone mode for instances of the decorated type. * * When a field holds an instance of a type decorated with `@defaultCloneMode`, the clone system - * uses the specified mode instead of the default Assignment behavior. This applies when the field - * has no explicit per-field clone decorator, or when the value is encountered inside a container - * (Array, Map, Set) that is being deep-cloned. + * uses the specified mode instead of the default Assignment behavior. * - * @param mode - The clone mode applied to instances of the decorated type + * Built-in defaults: + * - Entity / Component → `CloneMode.Remap` + * - ReferResource (Texture, Mesh, Material, etc.) → `CloneMode.Assignment` + * - Value-semantic config objects (RenderState, ParticleModule, etc.) → `CloneMode.Deep` * - * @example - * ```ts - * @defaultCloneMode(CloneMode.Deep) - * class MyConfig { - * value = 0; - * } - * ``` + * @param mode - The clone mode applied to instances of the decorated type */ export function defaultCloneMode(mode: CloneMode) { return function (target: Function): void { @@ -73,161 +56,170 @@ export function defaultCloneMode(mode: CloneMode) { /** * @internal * Clone manager. + * + * Opt-out model: all enumerable fields of an object are cloned unless marked `@ignoreClone`. + * HOW each field value is cloned depends on the value's runtime type (`@defaultCloneMode`): + * - primitive / null / undefined → assign by value. + * - function → skipped (transient; the clone's constructor re-establishes bound handlers). + * - Remap (Entity / Component) → resolve to the clone via the identity map. + * - Assignment (ReferResource / unknown types without @defaultCloneMode) → share the reference. + * - Deep (@defaultCloneMode(Deep) / copyFrom types) → recursively deep clone. + * + * Deep clone lifecycle (3-stage): + * 1. Construct — reuse the clone's existing slot value if same type, else `new ctor()`. + * 2. Populate — `copyFrom` (value-type fast path) OR recurse all fields (opt-out). + * 3. Finalize — `_cloneTo` post-clone hook for native sync / derived state rebuild. + * + * Cycles / shared sub-graphs dedup through the identity map. */ export class CloneManager { - /** @internal */ - static _subCloneModeMap = new Map(); - /** @internal */ - static _cloneModeMap = new Map(); + /** @internal Own `@ignoreClone` field names per class (excluding inherited). */ + static _subIgnoreMap = new Map>(); + /** @internal Flattened `@ignoreClone` field names per class (across the prototype chain), cached. */ + static _ignoreMap = new Map>(); private static _objectType = Object.getPrototypeOf(Object); /** - * Register clone mode. - * @param target - Clone target - * @param propertyKey - Clone property name - * @param mode - Clone mode + * Get the ignored field names of a type, flattened across its prototype chain. */ - 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); + static getIgnoredFields(type: Function): Set { + let fields = CloneManager._ignoreMap.get(type); + if (!fields) { + fields = new Set(); + CloneManager._ignoreMap.set(type, fields); + const objectType = CloneManager._objectType; + const subMap = CloneManager._subIgnoreMap; + let current = type; + while (current !== objectType) { + const own = subMap.get(current); + if (own) { + own.forEach((field) => fields.add(field)); + } + current = Object.getPrototypeOf(current); + } } - targetMap[propertyKey] = mode; + return fields; + } + + static copyProperty(source: Object, target: Object, k: string | number, cloneMap: Map): void { + target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap); } /** - * Get the clone mode according to the prototype chain. + * @internal + * Clone gate — decides how to clone one value based on its type. */ - 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); + static _cloneValue(value: any, reuse: any, cloneMap: Map): any { + if (!(value instanceof Object)) return value; + if (typeof value === "function") return reuse; + + const cloneMode = (value)._defaultCloneMode ?? CloneMode.Assignment; + if (cloneMode === CloneMode.Assignment) { + const reusedResource = <{ _addReferCount?(count: number): void; refCount?: number }>reuse; + if (reusedResource?._addReferCount) { + const presetRefCount = reusedResource.refCount; + presetRefCount !== undefined && + presetRefCount <= 0 && + Logger.error( + `CloneManager: the clone's preset ${reuse.constructor.name} holds no owned reference; ` + + `a constructor presetting a ref-counted resource must acquire it (assign via its setter or an explicit +1).` + ); + reusedResource._addReferCount(-1); } + (<{ _addReferCount?(count: number): void }>value)._addReferCount?.(1); + return value; } - return cloneModes; + if (cloneMode === CloneMode.Remap) { + return cloneMap.get(value) ?? value; + } + return CloneManager._deepClone(value, reuse, cloneMap); } - 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; + /** + * Deep-clone one object graph. Cycles / shared sub-graphs dedup through the identity map. + */ + private static _deepClone(value: any, reuse: any, cloneMap: Map): any { + const existing = cloneMap.get(value); + if (existing) return existing; + + // Value type (Vector3, Color, Matrix, ...) — copy in place. + if ((value).copyFrom) { + const dst = + reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); + cloneMap.set(value, dst); + (dst).copyFrom(value); + (value)._cloneTo?.(dst); + return dst; } - if (cloneMode === CloneMode.Ignore) return; + // Typed array — buffer copy. + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + const src = value; + if (reuse && reuse.constructor === src.constructor && (reuse).length === src.length) { + (reuse).set(src); + return reuse; + } + return src.slice(); + } - // If no per-field decorator, consult the value's type-level default clone mode - if (cloneMode === undefined && sourceProperty instanceof Object) { - const typeDefault = (sourceProperty)._defaultCloneMode; - if (typeDefault === CloneMode.Deep) { - cloneMode = CloneMode.Deep; - } else if (typeDefault === CloneMode.Shallow) { - cloneMode = CloneMode.Shallow; + // Array — fresh instance, each member through the gate. + if (Array.isArray(value)) { + const dst = new Array(value.length); + cloneMap.set(value, dst); + for (let i = 0, n = value.length; i < n; i++) { + dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); } - // typeDefault === undefined or Assignment → fall through to assignment below + return dst; } - // Primitives, undecorated (no type default), or @assignmentClone: direct assign - if (!(sourceProperty instanceof Object) || cloneMode === undefined || cloneMode === CloneMode.Assignment) { - target[k] = sourceProperty; - return; + // Map + if (value instanceof Map) { + const dst = new Map(); + cloneMap.set(value, dst); + value.forEach((v, key) => { + dst.set(CloneManager._cloneValue(key, undefined, cloneMap), CloneManager._cloneValue(v, undefined, cloneMap)); + }); + return dst; } - // @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, - undefined, - 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; - } + // Set + if (value instanceof Set) { + const dst = new Set(); + cloneMap.set(value, dst); + value.forEach((v) => dst.add(CloneManager._cloneValue(v, undefined, cloneMap))); + return dst; + } - 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; + // Object — reuse or construct, then populate all fields (opt-out) + finalize. + const dst = + reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); + cloneMap.set(value, dst); + const ignoredFields = CloneManager.getIgnoredFields(value.constructor); + for (const key in value) { + if (ignoredFields.has(key)) continue; + dst[key] = CloneManager._cloneValue(value[key], dst[key], cloneMap); + } + (value)._cloneTo?.(dst); + return dst; + } + + /** + * Copy every enumerable property of `source` into `target`, deep-cloning each value through + * the type-driven gate. + */ + static copyProperties(source: Object, target: Object, cloneMap: Map): void { + for (let k in source) { + CloneManager.copyProperty(source, target, k, cloneMap); } } - static deepCloneObject(source: Object, target: Object, deepInstanceMap: Map): void { + /** + * Deep clone all fields of source into target (bypasses ignore check). + */ + static deepCloneObject(source: Object, target: Object, cloneMap: Map): void { for (let k in source) { - CloneManager.cloneProperty(source, target, k, CloneMode.Deep, null, null, deepInstanceMap); + target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap); } } } diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 552d2f40f6..fc8daa794a 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -9,8 +9,8 @@ import { CloneMode } from "./enums/CloneMode"; export interface ICustomClone { /** * @internal - * Default clone mode for instances of this type when encountered as a value in a field. - * Set via `@defaultCloneMode`. Absence defaults to Assignment (shared reference). + * Default clone mode for instances of this type (set via `@defaultCloneMode`). + * Absence defaults to Assignment (shared reference). */ readonly _defaultCloneMode?: CloneMode; /** @@ -29,7 +29,7 @@ export interface ICustomClone { export class ComponentCloner { /** - * Clone component. + * Clone component (opt-out: all fields cloned except @ignoreClone). * @param source - Clone source * @param target - Clone target */ @@ -40,9 +40,17 @@ export class ComponentCloner { 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); + const ignoredFields = CloneManager.getIgnoredFields(source.constructor); + for (const k in source) { + if (ignoredFields.has(k)) continue; + const sourceProperty = source[k]; + // Remappable references (Entity/Component) + if (sourceProperty instanceof Object && (sourceProperty)._remap) { + target[k] = (sourceProperty)._remap(srcRoot, targetRoot); + continue; + } + // Type-driven clone + target[k] = CloneManager._cloneValue(sourceProperty, target[k], deepInstanceMap); } ((source as unknown))._cloneTo?.(target, srcRoot, targetRoot); } diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index 77158ce3ce..71177b577c 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -1,13 +1,11 @@ /** - * Clone mode. + * How a value is cloned, decided by its type (see `@defaultCloneMode`). */ export enum CloneMode { - /** Ignore clone. */ - Ignore, - /** Assignment clone. */ + /** Share the reference; a ref-counted resource is kept alive by the clone. */ Assignment, - /** Shallow clone. */ - Shallow, - /** Deep clone. */ - Deep + /** Recursively deep clone the value, producing an independent copy. */ + Deep, + /** Remap an Entity / Component reference to its clone within the cloned subtree. */ + Remap } From afce33dacc298b431b9e2a4daf182cda0acd1d19 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Wed, 17 Jun 2026 19:39:20 +0800 Subject: [PATCH 003/109] refactor(clone): mark value-semantic classes with @defaultCloneMode(Deep) Particle system: ParticleGeneratorModule (+ all subclasses via inheritance), MainModule, BaseShape (+ all shape subclasses), ParticleGenerator, ParticleCompositeCurve, ParticleCurve, CurveKey, ParticleCompositeGradient, ParticleGradient, GradientColorKey, GradientAlphaKey, Burst Post-process: PostProcessEffect (+ BloomEffect, TonemappingEffect via inheritance), PostProcessEffectParameter (+ Float/Bool/Color/Vector/Enum/Texture) Physics: JointLimits, JointMotor Other: VirtualCamera, Skin Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/VirtualCamera.ts | 4 +++- packages/core/src/mesh/Skin.ts | 4 +++- packages/core/src/particle/ParticleGenerator.ts | 4 +++- packages/core/src/particle/modules/Burst.ts | 4 +++- packages/core/src/particle/modules/MainModule.ts | 4 +++- .../core/src/particle/modules/ParticleCompositeCurve.ts | 4 +++- .../core/src/particle/modules/ParticleCompositeGradient.ts | 4 +++- packages/core/src/particle/modules/ParticleCurve.ts | 5 ++++- .../core/src/particle/modules/ParticleGeneratorModule.ts | 4 +++- packages/core/src/particle/modules/ParticleGradient.ts | 6 +++++- packages/core/src/particle/modules/shape/BaseShape.ts | 4 +++- packages/core/src/physics/joint/JointLimits.ts | 4 +++- packages/core/src/physics/joint/JointMotor.ts | 4 +++- packages/core/src/postProcess/PostProcessEffect.ts | 3 +++ packages/core/src/postProcess/PostProcessEffectParameter.ts | 3 +++ 15 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/core/src/VirtualCamera.ts b/packages/core/src/VirtualCamera.ts index 4c8598888e..8e49cf11ce 100644 --- a/packages/core/src/VirtualCamera.ts +++ b/packages/core/src/VirtualCamera.ts @@ -1,9 +1,11 @@ import { Matrix, Vector3 } from "@galacean/engine-math"; -import { ignoreClone } from "./clone/CloneManager"; +import { CloneMode } from "./clone/enums/CloneMode"; +import { ignoreClone, defaultCloneMode } from "./clone/CloneManager"; /** * @internal */ +@defaultCloneMode(CloneMode.Deep) export class VirtualCamera { isOrthographic: boolean = false; nearClipPlane: number = 0.1; diff --git a/packages/core/src/mesh/Skin.ts b/packages/core/src/mesh/Skin.ts index 18489805c5..46439cc0de 100644 --- a/packages/core/src/mesh/Skin.ts +++ b/packages/core/src/mesh/Skin.ts @@ -1,14 +1,16 @@ import { Matrix } from "@galacean/engine-math"; +import { CloneMode } from "../clone/enums/CloneMode"; import { Entity } from "../Entity"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { Utils } from "../Utils"; import { EngineObject } from "../base/EngineObject"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { deepClone, ignoreClone, defaultCloneMode } from "../clone/CloneManager"; import { SkinnedMeshRenderer } from "./SkinnedMeshRenderer"; /** * Skin used for skinned mesh renderer. */ +@defaultCloneMode(CloneMode.Deep) export class Skin extends EngineObject { /** Inverse bind matrices. */ @deepClone diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index ff72a1292c..b1465e0a0c 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1,6 +1,7 @@ import { BoundingBox, Color, Matrix, Quaternion, Vector2, Vector3 } from "@galacean/engine-math"; +import { CloneMode } from "../clone/enums/CloneMode"; import { Transform } from "../Transform"; -import { deepClone, ignoreClone } from "../clone/CloneManager"; +import { deepClone, ignoreClone, defaultCloneMode } from "../clone/CloneManager"; import { Primitive } from "../graphic/Primitive"; import { SubMesh } from "../graphic/SubMesh"; import { SubPrimitive } from "../graphic/SubPrimitive"; @@ -40,6 +41,7 @@ import { VelocityOverLifetimeModule } from "./modules/VelocityOverLifetimeModule /** * Particle Generator. */ +@defaultCloneMode(CloneMode.Deep) export class ParticleGenerator { private static _tempVector20 = new Vector2(); private static _tempVector21 = new Vector2(); diff --git a/packages/core/src/particle/modules/Burst.ts b/packages/core/src/particle/modules/Burst.ts index a444381dc2..3e39c1b464 100644 --- a/packages/core/src/particle/modules/Burst.ts +++ b/packages/core/src/particle/modules/Burst.ts @@ -1,9 +1,11 @@ -import { deepClone } from "../../clone/CloneManager"; +import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; /** * A burst is a particle emission event, where a number of particles are all emitted at the same time */ +@defaultCloneMode(CloneMode.Deep) export class Burst { public time: number; @deepClone diff --git a/packages/core/src/particle/modules/MainModule.ts b/packages/core/src/particle/modules/MainModule.ts index d17c156d9a..b85d5f6e51 100644 --- a/packages/core/src/particle/modules/MainModule.ts +++ b/packages/core/src/particle/modules/MainModule.ts @@ -1,6 +1,7 @@ import { Color, Rand, Vector3, Vector4 } from "@galacean/engine-math"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { TransformModifyFlags } from "../../Transform"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; import { ICustomClone } from "../../clone/ComponentCloner"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -11,6 +12,7 @@ import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { ParticleCompositeGradient } from "./ParticleCompositeGradient"; +@defaultCloneMode(CloneMode.Deep) export class MainModule implements ICustomClone { private _tempVector40 = new Vector4(); private static _vector3One = new Vector3(1, 1, 1); diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 587855ff08..6d98832822 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -1,5 +1,6 @@ import { Vector2 } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; +import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; import { UpdateFlagManager } from "../../UpdateFlagManager"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { CurveKey, ParticleCurve } from "./ParticleCurve"; @@ -7,6 +8,7 @@ import { CurveKey, ParticleCurve } from "./ParticleCurve"; /** * Particle composite curve. */ +@defaultCloneMode(CloneMode.Deep) export class ParticleCompositeCurve { @ignoreClone private _updateManager = new UpdateFlagManager(); diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index 59294998b4..9296aed6c7 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -1,11 +1,13 @@ import { Color } from "@galacean/engine-math"; -import { deepClone } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; +import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; import { ParticleGradientMode } from "../enums/ParticleGradientMode"; import { ParticleGradient } from "./ParticleGradient"; /** * Particle composite gradient. */ +@defaultCloneMode(CloneMode.Deep) export class ParticleCompositeGradient { /** The gradient mode. */ mode: ParticleGradientMode = ParticleGradientMode.Constant; diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index fce99c6b40..4a37f6cf87 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -1,9 +1,11 @@ import { UpdateFlagManager } from "../../UpdateFlagManager"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; +import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; /** * Particle curve. */ +@defaultCloneMode(CloneMode.Deep) export class ParticleCurve { @ignoreClone private _updateManager = new UpdateFlagManager(); @@ -162,6 +164,7 @@ export class ParticleCurve { /** * The key of the curve. */ +@defaultCloneMode(CloneMode.Deep) export class CurveKey { @ignoreClone private _updateManager = new UpdateFlagManager(); diff --git a/packages/core/src/particle/modules/ParticleGeneratorModule.ts b/packages/core/src/particle/modules/ParticleGeneratorModule.ts index e9a341cabc..59366a5216 100644 --- a/packages/core/src/particle/modules/ParticleGeneratorModule.ts +++ b/packages/core/src/particle/modules/ParticleGeneratorModule.ts @@ -1,4 +1,5 @@ -import { ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData, ShaderMacro } from "../../shader"; import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; @@ -6,6 +7,7 @@ import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; /** * Particle generator module. */ +@defaultCloneMode(CloneMode.Deep) export abstract class ParticleGeneratorModule { @ignoreClone protected _generator: ParticleGenerator; diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index ff7bb0a5ce..cffd1a57e1 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -1,9 +1,11 @@ import { Color } from "@galacean/engine-math"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; +import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; /** * Particle gradient. */ +@defaultCloneMode(CloneMode.Deep) export class ParticleGradient { @deepClone private _colorKeys: GradientColorKey[] = []; @@ -222,6 +224,7 @@ export class ParticleGradient { /** * The color key of the particle gradient. */ +@defaultCloneMode(CloneMode.Deep) export class GradientColorKey { /** @internal */ _onValueChanged: () => void = null; @@ -270,6 +273,7 @@ export class GradientColorKey { /** * The alpha key of the particle gradient. */ +@defaultCloneMode(CloneMode.Deep) export class GradientAlphaKey { /** @internal */ _onValueChanged: () => void = null; diff --git a/packages/core/src/particle/modules/shape/BaseShape.ts b/packages/core/src/particle/modules/shape/BaseShape.ts index 9e00891b59..c765e35c20 100644 --- a/packages/core/src/particle/modules/shape/BaseShape.ts +++ b/packages/core/src/particle/modules/shape/BaseShape.ts @@ -1,11 +1,13 @@ import { BoundingBox, MathUtil, Matrix, Quaternion, Rand, Vector2, Vector3 } from "@galacean/engine-math"; +import { CloneMode } from "../../../clone/enums/CloneMode"; import { ParticleShapeType } from "./enums/ParticleShapeType"; import { UpdateFlagManager } from "../../../UpdateFlagManager"; -import { deepClone, ignoreClone } from "../../../clone/CloneManager"; +import { deepClone, ignoreClone, defaultCloneMode } from "../../../clone/CloneManager"; /** * Base class for all particle shapes. */ +@defaultCloneMode(CloneMode.Deep) export abstract class BaseShape { /** @internal */ static _tempVector20 = new Vector2(); diff --git a/packages/core/src/physics/joint/JointLimits.ts b/packages/core/src/physics/joint/JointLimits.ts index 3d7ff811be..0b29ee8ae4 100644 --- a/packages/core/src/physics/joint/JointLimits.ts +++ b/packages/core/src/physics/joint/JointLimits.ts @@ -1,9 +1,11 @@ -import { deepClone } from "../../clone/CloneManager"; +import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { UpdateFlagManager } from "../../UpdateFlagManager"; /** * JointLimits is used to limit the joints angle. */ +@defaultCloneMode(CloneMode.Deep) export class JointLimits { @deepClone /** @internal */ diff --git a/packages/core/src/physics/joint/JointMotor.ts b/packages/core/src/physics/joint/JointMotor.ts index 0f381c1a6c..7a10678a4f 100644 --- a/packages/core/src/physics/joint/JointMotor.ts +++ b/packages/core/src/physics/joint/JointMotor.ts @@ -1,9 +1,11 @@ -import { deepClone } from "../../clone/CloneManager"; +import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { UpdateFlagManager } from "../../UpdateFlagManager"; /** * The JointMotor is used to motorize a joint. */ +@defaultCloneMode(CloneMode.Deep) export class JointMotor { @deepClone /** @internal */ diff --git a/packages/core/src/postProcess/PostProcessEffect.ts b/packages/core/src/postProcess/PostProcessEffect.ts index a2a29d382a..943a6c01ad 100644 --- a/packages/core/src/postProcess/PostProcessEffect.ts +++ b/packages/core/src/postProcess/PostProcessEffect.ts @@ -1,8 +1,11 @@ import { PostProcessEffectParameter } from "./PostProcessEffectParameter"; +import { defaultCloneMode } from "../clone/CloneManager"; +import { CloneMode } from "../clone/enums/CloneMode"; /** * The base class for post process effect. */ +@defaultCloneMode(CloneMode.Deep) export class PostProcessEffect { private _enabled = true; private _parameters: PostProcessEffectParameter[] = []; diff --git a/packages/core/src/postProcess/PostProcessEffectParameter.ts b/packages/core/src/postProcess/PostProcessEffectParameter.ts index 899860ba76..efce5533ff 100644 --- a/packages/core/src/postProcess/PostProcessEffectParameter.ts +++ b/packages/core/src/postProcess/PostProcessEffectParameter.ts @@ -1,4 +1,6 @@ import { Color, MathUtil, Vector2, Vector3, Vector4 } from "@galacean/engine-math"; +import { defaultCloneMode } from "../clone/CloneManager"; +import { CloneMode } from "../clone/enums/CloneMode"; import { Texture } from "../texture"; /** @@ -6,6 +8,7 @@ import { Texture } from "../texture"; * @remarks * The parameter will be mixed to a final value and be used in post process manager. */ +@defaultCloneMode(CloneMode.Deep) export abstract class PostProcessEffectParameter { /** * Whether the parameter is enabled. From 94e6a030780300b161065dd7ce125545ec6d0a9e Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 22 Jun 2026 15:56:23 +0800 Subject: [PATCH 004/109] fix(clone): remap nested Entity/Component refs via two-pass clone Mark Entity/Component with @defaultCloneMode(Remap) and split Entity.clone() into a register-then-copy two-pass: pre-register every source Entity/Component to its clone in the identity map across the whole subtree, so references nested in arrays/maps/objects are remapped through the clone gate, not just top-level fields. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/Component.ts | 4 +++- packages/core/src/Entity.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/core/src/Component.ts b/packages/core/src/Component.ts index 2ebafbcd05..006d5ee88d 100644 --- a/packages/core/src/Component.ts +++ b/packages/core/src/Component.ts @@ -1,7 +1,8 @@ import { IReferable } from "./asset/IReferable"; import { EngineObject } from "./base"; -import { assignmentClone, ignoreClone } from "./clone/CloneManager"; +import { assignmentClone, defaultCloneMode, ignoreClone } from "./clone/CloneManager"; import { CloneUtils } from "./clone/CloneUtils"; +import { CloneMode } from "./clone/enums/CloneMode"; import { Entity } from "./Entity"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; import { Scene } from "./Scene"; @@ -9,6 +10,7 @@ import { Scene } from "./Scene"; /** * The base class of the components. */ +@defaultCloneMode(CloneMode.Remap) export class Component extends EngineObject { /** @internal */ _entity: Entity; diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 063fbe1c1c..e0a20a3930 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -10,8 +10,10 @@ import { Transform } from "./Transform"; import { UpdateFlagManager } from "./UpdateFlagManager"; import { ReferResource } from "./asset/ReferResource"; import { EngineObject } from "./base"; +import { defaultCloneMode } from "./clone/CloneManager"; import { CloneUtils } from "./clone/CloneUtils"; import { ComponentCloner } from "./clone/ComponentCloner"; +import { CloneMode } from "./clone/enums/CloneMode"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; import { EntityModifyFlags } from "./enums/EntityModifyFlags"; import { DisorderedArray } from "./utils/DisorderedArray"; @@ -19,6 +21,7 @@ import { DisorderedArray } from "./utils/DisorderedArray"; /** * Entity, be used as components container. */ +@defaultCloneMode(CloneMode.Remap) export class Entity extends EngineObject { /** @internal */ static _tempComponentConstructors: ComponentConstructor[] = []; @@ -421,7 +424,9 @@ export class Entity extends EngineObject { */ clone(): Entity { const cloneEntity = this._createCloneEntity(); - this._parseCloneEntity(this, cloneEntity, this, cloneEntity, new Map()); + const cloneMap = new Map(); + this._registerCloneMap(this, cloneEntity, cloneMap); + this._parseCloneEntity(this, cloneEntity, this, cloneEntity, cloneMap); return cloneEntity; } @@ -471,6 +476,26 @@ export class Entity extends EngineObject { return cloneEntity; } + /** + * Register every source Entity / Component to its clone in the identity map, so the value-copy + * pass (`_parseCloneEntity`) can remap references — including ones nested in arrays / maps / objects — + * through the clone gate. Must complete for the whole subtree before any value is copied, because a + * component anywhere in the tree may reference an entity anywhere else in it. + */ + private _registerCloneMap(src: Entity, target: Entity, cloneMap: Map): void { + cloneMap.set(src, target); + const srcComponents = src._components; + const targetComponents = target._components; + for (let i = 0, n = srcComponents.length; i < n; i++) { + cloneMap.set(srcComponents[i], targetComponents[i]); + } + const srcChildren = src._children; + const targetChildren = target._children; + for (let i = 0, n = srcChildren.length; i < n; i++) { + this._registerCloneMap(srcChildren[i], targetChildren[i], cloneMap); + } + } + private _parseCloneEntity( src: Entity, target: Entity, From a0e5fa9b95d246366d1be1833cff0337f3ca0d55 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 22 Jun 2026 23:46:33 +0800 Subject: [PATCH 005/109] refactor(clone): three-tier mode resolution + container default deep - Re-register @deepClone/@assignmentClone/@ignoreClone as field-level modes (highest priority). - Containers (Array/Map/Set/TypedArray/plain object) default to deep clone. - Type defaults: ReferResource -> Assignment, math types -> Deep, UpdateFlagManager -> Ignore. - Thread srcRoot/targetRoot through the gate so Signal._cloneTo can remap listeners. - Add CloneMode.Ignore; drop erroneous @deepClone on Joint limits/motor _updateFlagManager. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/UpdateFlagManager.ts | 3 + packages/core/src/asset/ReferResource.ts | 3 + packages/core/src/clone/CloneManager.ts | 167 +++++++++++++----- packages/core/src/clone/ComponentCloner.ts | 11 +- packages/core/src/clone/enums/CloneMode.ts | 4 +- packages/core/src/physics/joint/Joint.ts | 4 +- .../core/src/physics/joint/JointLimits.ts | 3 +- packages/core/src/physics/joint/JointMotor.ts | 3 +- tests/src/core/CloneUtils.test.ts | 6 +- 9 files changed, 149 insertions(+), 55 deletions(-) diff --git a/packages/core/src/UpdateFlagManager.ts b/packages/core/src/UpdateFlagManager.ts index 0e1e98ec90..14df3744c3 100644 --- a/packages/core/src/UpdateFlagManager.ts +++ b/packages/core/src/UpdateFlagManager.ts @@ -1,9 +1,12 @@ import { UpdateFlag } from "./UpdateFlag"; import { Utils } from "./Utils"; +import { defaultCloneMode } from "./clone/CloneManager"; +import { CloneMode } from "./clone/enums/CloneMode"; /** * @internal */ +@defaultCloneMode(CloneMode.Ignore) export class UpdateFlagManager { /** Monotonic counter bumped on every `dispatch`; consumers can snapshot it for lazy pull-style cache invalidation. */ version = 0; diff --git a/packages/core/src/asset/ReferResource.ts b/packages/core/src/asset/ReferResource.ts index 1460d048e8..a317c9c0c9 100644 --- a/packages/core/src/asset/ReferResource.ts +++ b/packages/core/src/asset/ReferResource.ts @@ -1,10 +1,13 @@ import { EngineObject } from "../base/EngineObject"; import { Engine } from "../Engine"; +import { defaultCloneMode } from "../clone/CloneManager"; +import { CloneMode } from "../clone/enums/CloneMode"; import { IReferable } from "./IReferable"; /** * The base class of assets, with reference counting capability. */ +@defaultCloneMode(CloneMode.Assignment) export abstract class ReferResource extends EngineObject implements IReferable { /** Whether to ignore the garbage collection check, if it is true, it will not be affected by ResourceManager.gc(). */ isGCIgnored: boolean = false; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index a9cf86f72d..ab84a927ba 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,38 +1,51 @@ +import { + BoundingBox, + BoundingFrustum, + BoundingSphere, + Color, + Matrix, + Matrix3x3, + Plane, + Quaternion, + Rect, + SphericalHarmonics3, + Vector2, + Vector3, + Vector4 +} from "@galacean/engine-math"; import { TypedArray } from "../base/Constant"; import { Logger } from "../base/Logger"; import { ICustomClone } from "./ComponentCloner"; import { CloneMode } from "./enums/CloneMode"; /** - * Property decorator, ignore the property when cloning. + * Property decorator — deep clone this field, overriding the value type's default clone mode. + * Field-level decorators have the highest priority. */ -export function ignoreClone(target: Object, propertyKey: string): void { - let fields = CloneManager._subIgnoreMap.get(target.constructor); - if (!fields) { - fields = new Set(); - CloneManager._subIgnoreMap.set(target.constructor, fields); - } - fields.add(propertyKey); - CloneManager._ignoreMap.clear(); +export function deepClone(target: Object, propertyKey: string): void { + CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); } /** - * @deprecated No longer needed — Assignment is the default clone behavior for unrecognized types. - * Kept for backward compatibility; acts as a no-op. + * Property decorator — assign (share the reference) this field, overriding the value type's default clone mode. */ -export function assignmentClone(_target: Object, _propertyKey: string): void {} +export function assignmentClone(target: Object, propertyKey: string): void { + CloneManager._registerFieldMode(target, propertyKey, CloneMode.Assignment); +} /** - * @deprecated Use `@defaultCloneMode(CloneMode.Deep)` on the class instead. - * Kept for backward compatibility; acts as a no-op. + * Property decorator — ignore this field when cloning; keep the clone's own constructor-built value. */ -export function shallowClone(_target: Object, _propertyKey: string): void {} +export function ignoreClone(target: Object, propertyKey: string): void { + CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore); +} /** - * @deprecated Use `@defaultCloneMode(CloneMode.Deep)` on the class instead. - * Kept for backward compatibility; acts as a no-op. + * @deprecated Shallow clone is no longer a distinct mode; treated as deep clone. */ -export function deepClone(_target: Object, _propertyKey: string): void {} +export function shallowClone(target: Object, propertyKey: string): void { + CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); +} /** * Class decorator that sets the default clone mode for instances of the decorated type. @@ -73,33 +86,49 @@ export function defaultCloneMode(mode: CloneMode) { * Cycles / shared sub-graphs dedup through the identity map. */ export class CloneManager { - /** @internal Own `@ignoreClone` field names per class (excluding inherited). */ - static _subIgnoreMap = new Map>(); - /** @internal Flattened `@ignoreClone` field names per class (across the prototype chain), cached. */ - static _ignoreMap = new Map>(); + /** @internal Own field-level clone modes per class (excluding inherited), from `@deepClone`/`@assignmentClone`/`@ignoreClone`. */ + static _subFieldModeMap = new Map>(); + /** @internal Flattened field-level clone modes per class (across the prototype chain), cached. */ + static _fieldModeMap = new Map>(); private static _objectType = Object.getPrototypeOf(Object); /** - * Get the ignored field names of a type, flattened across its prototype chain. + * @internal + * Register a field-level clone mode (highest priority — overrides the value type's `@defaultCloneMode`). */ - static getIgnoredFields(type: Function): Set { - let fields = CloneManager._ignoreMap.get(type); + static _registerFieldMode(target: Object, propertyKey: string, mode: CloneMode): void { + let fields = CloneManager._subFieldModeMap.get(target.constructor); if (!fields) { - fields = new Set(); - CloneManager._ignoreMap.set(type, fields); + fields = new Map(); + CloneManager._subFieldModeMap.set(target.constructor, fields); + } + fields.set(propertyKey, mode); + CloneManager._fieldModeMap.clear(); + } + + /** + * Get the field-level clone modes of a type, flattened across its prototype chain. + */ + static getFieldModes(type: Function): Map { + let modes = CloneManager._fieldModeMap.get(type); + if (!modes) { + modes = new Map(); + CloneManager._fieldModeMap.set(type, modes); const objectType = CloneManager._objectType; - const subMap = CloneManager._subIgnoreMap; + const subMap = CloneManager._subFieldModeMap; let current = type; while (current !== objectType) { const own = subMap.get(current); if (own) { - own.forEach((field) => fields.add(field)); + own.forEach((mode, key) => { + if (!modes.has(key)) modes.set(key, mode); + }); } current = Object.getPrototypeOf(current); } } - return fields; + return modes; } static copyProperty(source: Object, target: Object, k: string | number, cloneMap: Map): void { @@ -110,11 +139,34 @@ export class CloneManager { * @internal * Clone gate — decides how to clone one value based on its type. */ - static _cloneValue(value: any, reuse: any, cloneMap: Map): any { + static _cloneValue( + value: any, + reuse: any, + cloneMap: Map, + fieldMode?: CloneMode, + srcRoot?: any, + targetRoot?: any + ): any { if (!(value instanceof Object)) return value; if (typeof value === "function") return reuse; - const cloneMode = (value)._defaultCloneMode ?? CloneMode.Assignment; + // Mode priority: field decorator (highest) → container default deep → type's `@defaultCloneMode` → Assignment. + let cloneMode = fieldMode; + if (cloneMode === undefined) { + if ( + Array.isArray(value) || + value instanceof Map || + value instanceof Set || + ArrayBuffer.isView(value) || + value.constructor === Object + ) { + cloneMode = CloneMode.Deep; + } else { + cloneMode = (value)._defaultCloneMode ?? CloneMode.Assignment; + } + } + + if (cloneMode === CloneMode.Ignore) return reuse; if (cloneMode === CloneMode.Assignment) { const reusedResource = <{ _addReferCount?(count: number): void; refCount?: number }>reuse; if (reusedResource?._addReferCount) { @@ -133,13 +185,20 @@ export class CloneManager { if (cloneMode === CloneMode.Remap) { return cloneMap.get(value) ?? value; } - return CloneManager._deepClone(value, reuse, cloneMap); + return CloneManager._deepClone(value, reuse, cloneMap, srcRoot, targetRoot); } /** * Deep-clone one object graph. Cycles / shared sub-graphs dedup through the identity map. + * `srcRoot`/`targetRoot` are threaded to `_cloneTo` hooks that remap entity/component references. */ - private static _deepClone(value: any, reuse: any, cloneMap: Map): any { + private static _deepClone( + value: any, + reuse: any, + cloneMap: Map, + srcRoot?: any, + targetRoot?: any + ): any { const existing = cloneMap.get(value); if (existing) return existing; @@ -149,7 +208,7 @@ export class CloneManager { reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); cloneMap.set(value, dst); (dst).copyFrom(value); - (value)._cloneTo?.(dst); + (value)._cloneTo?.(dst, srcRoot, targetRoot); return dst; } @@ -168,7 +227,7 @@ export class CloneManager { const dst = new Array(value.length); cloneMap.set(value, dst); for (let i = 0, n = value.length; i < n; i++) { - dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); + dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap, undefined, srcRoot, targetRoot); } return dst; } @@ -178,7 +237,10 @@ export class CloneManager { const dst = new Map(); cloneMap.set(value, dst); value.forEach((v, key) => { - dst.set(CloneManager._cloneValue(key, undefined, cloneMap), CloneManager._cloneValue(v, undefined, cloneMap)); + dst.set( + CloneManager._cloneValue(key, undefined, cloneMap, undefined, srcRoot, targetRoot), + CloneManager._cloneValue(v, undefined, cloneMap, undefined, srcRoot, targetRoot) + ); }); return dst; } @@ -187,7 +249,7 @@ export class CloneManager { if (value instanceof Set) { const dst = new Set(); cloneMap.set(value, dst); - value.forEach((v) => dst.add(CloneManager._cloneValue(v, undefined, cloneMap))); + value.forEach((v) => dst.add(CloneManager._cloneValue(v, undefined, cloneMap, undefined, srcRoot, targetRoot))); return dst; } @@ -195,12 +257,13 @@ export class CloneManager { const dst = reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); cloneMap.set(value, dst); - const ignoredFields = CloneManager.getIgnoredFields(value.constructor); + const fieldModes = CloneManager.getFieldModes(value.constructor); for (const key in value) { - if (ignoredFields.has(key)) continue; - dst[key] = CloneManager._cloneValue(value[key], dst[key], cloneMap); + const fieldMode = fieldModes.get(key); + if (fieldMode === CloneMode.Ignore) continue; + dst[key] = CloneManager._cloneValue(value[key], dst[key], cloneMap, fieldMode, srcRoot, targetRoot); } - (value)._cloneTo?.(dst); + (value)._cloneTo?.(dst, srcRoot, targetRoot); return dst; } @@ -223,3 +286,23 @@ export class CloneManager { } } } + +// Built-in default clone mode for math value types. The math package cannot depend on core's +// `@defaultCloneMode`, so they are registered here instead (core → math is the normal dependency +// direction). All are value-semantic and always deep cloned. +const _markDeep = defaultCloneMode(CloneMode.Deep); +[ + Vector2, + Vector3, + Vector4, + Quaternion, + Matrix, + Matrix3x3, + Color, + Rect, + BoundingBox, + BoundingFrustum, + BoundingSphere, + Plane, + SphericalHarmonics3 +].forEach((type) => _markDeep(type)); diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index fc8daa794a..257724ce13 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -40,17 +40,18 @@ export class ComponentCloner { targetRoot: Entity, deepInstanceMap: Map ): void { - const ignoredFields = CloneManager.getIgnoredFields(source.constructor); + const fieldModes = CloneManager.getFieldModes(source.constructor); for (const k in source) { - if (ignoredFields.has(k)) continue; const sourceProperty = source[k]; - // Remappable references (Entity/Component) + // Entity/Component references are always remapped (reference correctness, above field ignore). if (sourceProperty instanceof Object && (sourceProperty)._remap) { target[k] = (sourceProperty)._remap(srcRoot, targetRoot); continue; } - // Type-driven clone - target[k] = CloneManager._cloneValue(sourceProperty, target[k], deepInstanceMap); + const fieldMode = fieldModes.get(k); + if (fieldMode === CloneMode.Ignore) continue; + // Field decorator (highest) → value-shape / type default. + target[k] = CloneManager._cloneValue(sourceProperty, target[k], deepInstanceMap, fieldMode, srcRoot, targetRoot); } ((source as unknown))._cloneTo?.(target, srcRoot, targetRoot); } diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index 71177b577c..cd83b51de3 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -7,5 +7,7 @@ export enum CloneMode { /** Recursively deep clone the value, producing an independent copy. */ Deep, /** Remap an Entity / Component reference to its clone within the cloned subtree. */ - Remap + Remap, + /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */ + Ignore } diff --git a/packages/core/src/physics/joint/Joint.ts b/packages/core/src/physics/joint/Joint.ts index 8d9f8af178..c1a299682c 100644 --- a/packages/core/src/physics/joint/Joint.ts +++ b/packages/core/src/physics/joint/Joint.ts @@ -4,7 +4,8 @@ import { Component } from "../../Component"; import { DependentMode, dependentComponents } from "../../ComponentsDependencies"; import { Entity } from "../../Entity"; import { TransformModifyFlags } from "../../Transform"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { deepClone, defaultCloneMode, ignoreClone } from "../../clone/CloneManager"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { Collider } from "../Collider"; import { DynamicCollider } from "../DynamicCollider"; @@ -321,6 +322,7 @@ enum AnchorOwner { /** * @internal */ +@defaultCloneMode(CloneMode.Deep) class JointColliderInfo { collider: Collider = null; @deepClone diff --git a/packages/core/src/physics/joint/JointLimits.ts b/packages/core/src/physics/joint/JointLimits.ts index 0b29ee8ae4..6db82b58b4 100644 --- a/packages/core/src/physics/joint/JointLimits.ts +++ b/packages/core/src/physics/joint/JointLimits.ts @@ -1,4 +1,4 @@ -import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { defaultCloneMode } from "../../clone/CloneManager"; import { CloneMode } from "../../clone/enums/CloneMode"; import { UpdateFlagManager } from "../../UpdateFlagManager"; @@ -7,7 +7,6 @@ import { UpdateFlagManager } from "../../UpdateFlagManager"; */ @defaultCloneMode(CloneMode.Deep) export class JointLimits { - @deepClone /** @internal */ _updateFlagManager = new UpdateFlagManager(); diff --git a/packages/core/src/physics/joint/JointMotor.ts b/packages/core/src/physics/joint/JointMotor.ts index 7a10678a4f..cfa82572c4 100644 --- a/packages/core/src/physics/joint/JointMotor.ts +++ b/packages/core/src/physics/joint/JointMotor.ts @@ -1,4 +1,4 @@ -import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { defaultCloneMode } from "../../clone/CloneManager"; import { CloneMode } from "../../clone/enums/CloneMode"; import { UpdateFlagManager } from "../../UpdateFlagManager"; @@ -7,7 +7,6 @@ import { UpdateFlagManager } from "../../UpdateFlagManager"; */ @defaultCloneMode(CloneMode.Deep) export class JointMotor { - @deepClone /** @internal */ _updateFlagManager = new UpdateFlagManager(); diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index e583d6d8ab..400dbe4c3c 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -220,7 +220,7 @@ describe("Clone remap", async () => { rootEntity.destroy(); }); - it("primitive and plain object props should not be affected", () => { + 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); @@ -236,7 +236,9 @@ describe("Clone remap", async () => { expect(clonedScript.speed).eq(42); expect(clonedScript.name2).eq("test"); expect(clonedScript.flag).eq(true); - expect(clonedScript.data).eq(obj); + // 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(); }); From 297508f5b0c3b59bb2ef1bc79a352b8e1606b282 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 23 Jun 2026 00:05:44 +0800 Subject: [PATCH 006/109] refactor(clone): drop 171 field clone-mode decorators, rely on type defaults - Remove @deepClone/@assignmentClone/@shallowClone field decorators (171) across core & ui. - Fields resolve via container default deep + type-level @defaultCloneMode + Assignment fallback. - Mark ShaderData & Signal Deep; ShaderData adds _cloneTo delegating to cloneTo. - Clean up now-unused clone decorator imports. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/2d/sprite/SpriteMask.ts | 8 +------- packages/core/src/2d/sprite/SpriteRenderer.ts | 9 +-------- packages/core/src/2d/text/TextRenderer.ts | 16 +--------------- packages/core/src/Camera.ts | 9 +-------- packages/core/src/Component.ts | 3 +-- packages/core/src/Renderer.ts | 7 +------ packages/core/src/Signal.ts | 4 +++- packages/core/src/Transform.ts | 3 +-- packages/core/src/animation/Animator.ts | 4 +--- packages/core/src/audio/AudioSource.ts | 7 +------ packages/core/src/lighting/Light.ts | 3 +-- packages/core/src/mesh/Skin.ts | 5 +---- packages/core/src/mesh/SkinnedMeshRenderer.ts | 4 +--- packages/core/src/particle/ParticleGenerator.ts | 13 +------------ packages/core/src/particle/ParticleRenderer.ts | 4 +--- packages/core/src/particle/modules/Burst.ts | 3 +-- .../particle/modules/ColorOverLifetimeModule.ts | 3 +-- .../core/src/particle/modules/EmissionModule.ts | 6 +----- .../particle/modules/ForceOverLifetimeModule.ts | 5 +---- .../modules/LimitVelocityOverLifetimeModule.ts | 6 +----- packages/core/src/particle/modules/MainModule.ts | 13 +------------ .../core/src/particle/modules/NoiseModule.ts | 5 +---- .../particle/modules/ParticleCompositeCurve.ts | 4 +--- .../modules/ParticleCompositeGradient.ts | 6 +----- .../core/src/particle/modules/ParticleCurve.ts | 3 +-- .../src/particle/modules/ParticleGradient.ts | 4 +--- .../modules/RotationOverLifetimeModule.ts | 5 +---- .../particle/modules/SizeOverLifetimeModule.ts | 5 +---- .../modules/TextureSheetAnimationModule.ts | 5 +---- .../modules/VelocityOverLifetimeModule.ts | 5 +---- .../core/src/particle/modules/shape/BaseShape.ts | 5 +---- .../core/src/particle/modules/shape/BoxShape.ts | 2 -- packages/core/src/physics/CharacterController.ts | 3 +-- packages/core/src/physics/Collider.ts | 3 +-- packages/core/src/physics/joint/HingeJoint.ts | 5 +---- packages/core/src/physics/joint/Joint.ts | 6 +----- .../core/src/physics/shape/BoxColliderShape.ts | 3 +-- packages/core/src/physics/shape/ColliderShape.ts | 4 +--- packages/core/src/postProcess/PostProcess.ts | 2 -- packages/core/src/shader/ShaderData.ts | 12 +++++++++++- packages/core/src/shader/state/BlendState.ts | 4 +--- packages/core/src/shader/state/RenderState.ts | 6 +----- packages/core/src/trail/TrailRenderer.ts | 6 +----- packages/ui/src/component/UICanvas.ts | 5 ----- packages/ui/src/component/UIGroup.ts | 5 +---- packages/ui/src/component/UIRenderer.ts | 3 --- packages/ui/src/component/UITransform.ts | 1 - packages/ui/src/component/advanced/Button.ts | 3 +-- packages/ui/src/component/advanced/Image.ts | 2 -- packages/ui/src/component/advanced/Text.ts | 10 ---------- .../src/component/interactive/UIInteractive.ts | 2 -- 51 files changed, 55 insertions(+), 214 deletions(-) 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..c2911415ff 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; /** diff --git a/packages/core/src/Camera.ts b/packages/core/src/Camera.ts index 55d904f6c9..eb8aabb229 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; @@ -156,17 +154,12 @@ export class Camera extends Component { 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(); /** diff --git a/packages/core/src/Component.ts b/packages/core/src/Component.ts index 006d5ee88d..13d0cb2235 100644 --- a/packages/core/src/Component.ts +++ b/packages/core/src/Component.ts @@ -1,6 +1,6 @@ import { IReferable } from "./asset/IReferable"; import { EngineObject } from "./base"; -import { assignmentClone, defaultCloneMode, ignoreClone } from "./clone/CloneManager"; +import { defaultCloneMode, ignoreClone } from "./clone/CloneManager"; import { CloneUtils } from "./clone/CloneUtils"; import { CloneMode } from "./clone/enums/CloneMode"; import { Entity } from "./Entity"; @@ -25,7 +25,6 @@ export class Component extends EngineObject { @ignoreClone private _phasedActive: boolean = false; - @assignmentClone private _enabled: boolean = true; /** 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 a18b86cf5a..1671fd55e8 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,13 +1,15 @@ import { Component } from "./Component"; import { Entity } from "./Entity"; import { CloneUtils } from "./clone/CloneUtils"; -import { ignoreClone } from "./clone/CloneManager"; +import { defaultCloneMode, ignoreClone } from "./clone/CloneManager"; +import { CloneMode } from "./clone/enums/CloneMode"; import { SafeLoopArray } from "./utils/SafeLoopArray"; /** * Signal is a typed event mechanism for Galacean Engine. * @typeParam T - Tuple type of the signal arguments */ +@defaultCloneMode(CloneMode.Deep) export class Signal { @ignoreClone private _listeners: SafeLoopArray> = new SafeLoopArray>(); diff --git a/packages/core/src/Transform.ts b/packages/core/src/Transform.ts index f822474dcc..b61d29a6da 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/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 42fc2ee3fa..03d1a93cd7 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"; @@ -36,7 +36,6 @@ 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; /** @internal */ @@ -44,7 +43,6 @@ export class Animator extends Component { /** @internal */ _onUpdateIndex = -1; - @assignmentClone protected _animatorController: AnimatorController; @ignoreClone protected _controllerUpdateFlag: BoolUpdateFlag; diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index 29a3ed8bc1..3da4f3857c 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; /** 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 46439cc0de..6ca6957e8a 100644 --- a/packages/core/src/mesh/Skin.ts +++ b/packages/core/src/mesh/Skin.ts @@ -4,7 +4,7 @@ import { Entity } from "../Entity"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { Utils } from "../Utils"; import { EngineObject } from "../base/EngineObject"; -import { deepClone, ignoreClone, defaultCloneMode } from "../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../clone/CloneManager"; import { SkinnedMeshRenderer } from "./SkinnedMeshRenderer"; /** @@ -13,18 +13,15 @@ import { SkinnedMeshRenderer } from "./SkinnedMeshRenderer"; @defaultCloneMode(CloneMode.Deep) export class Skin extends EngineObject { /** 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; diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts index 4d7f726184..53d2cbd4b7 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,7 +29,6 @@ export class SkinnedMeshRenderer extends MeshRenderer { @ignoreClone _condensedBlendShapeWeights: Float32Array; - @deepClone private _localBounds: BoundingBox = new BoundingBox(); @ignoreClone @@ -42,7 +41,6 @@ export class SkinnedMeshRenderer extends MeshRenderer { @ignoreClone private _jointTexture: Texture2D; - @deepClone private _skin: Skin; /** diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index b1465e0a0c..80f566ac4d 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1,7 +1,7 @@ import { BoundingBox, Color, Matrix, Quaternion, Vector2, Vector3 } from "@galacean/engine-math"; import { CloneMode } from "../clone/enums/CloneMode"; import { Transform } from "../Transform"; -import { deepClone, ignoreClone, defaultCloneMode } from "../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../clone/CloneManager"; import { Primitive } from "../graphic/Primitive"; import { SubMesh } from "../graphic/SubMesh"; import { SubPrimitive } from "../graphic/SubPrimitive"; @@ -61,37 +61,26 @@ 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; /** Custom data module. */ - @deepClone readonly customData: CustomDataModule; /** @internal */ diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts index 323d5d8e50..508afcd6eb 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 3e39c1b464..9f07aedc1b 100644 --- a/packages/core/src/particle/modules/Burst.ts +++ b/packages/core/src/particle/modules/Burst.ts @@ -1,4 +1,4 @@ -import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { defaultCloneMode } from "../../clone/CloneManager"; import { CloneMode } from "../../clone/enums/CloneMode"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; @@ -8,7 +8,6 @@ import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; @defaultCloneMode(CloneMode.Deep) export class Burst { public time: number; - @deepClone public count: ParticleCompositeCurve; private _cycles: number; 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/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index a2d303a7d3..88d1baf038 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; 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 b85d5f6e51..74b148e9d4 100644 --- a/packages/core/src/particle/modules/MainModule.ts +++ b/packages/core/src/particle/modules/MainModule.ts @@ -1,7 +1,7 @@ import { Color, Rand, Vector3, Vector4 } from "@galacean/engine-math"; import { CloneMode } from "../../clone/enums/CloneMode"; import { TransformModifyFlags } from "../../Transform"; -import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; import { ICustomClone } from "../../clone/ComponentCloner"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -32,25 +32,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. */ @@ -85,18 +80,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 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 6d98832822..6e2a2925f2 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -1,6 +1,6 @@ import { Vector2 } from "@galacean/engine-math"; import { CloneMode } from "../../clone/enums/CloneMode"; -import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; import { UpdateFlagManager } from "../../UpdateFlagManager"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { CurveKey, ParticleCurve } from "./ParticleCurve"; @@ -15,9 +15,7 @@ export class ParticleCompositeCurve { private _mode = ParticleCurveMode.Constant; private _constantMin = 0; private _constantMax = 0; - @deepClone private _curveMin: ParticleCurve; - @deepClone private _curveMax: ParticleCurve; @ignoreClone private _updateDispatch: () => void; diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index 9296aed6c7..1a63b54a1f 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -1,6 +1,6 @@ import { Color } from "@galacean/engine-math"; import { CloneMode } from "../../clone/enums/CloneMode"; -import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { defaultCloneMode } from "../../clone/CloneManager"; import { ParticleGradientMode } from "../enums/ParticleGradientMode"; import { ParticleGradient } from "./ParticleGradient"; @@ -12,16 +12,12 @@ export class ParticleCompositeGradient { /** 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(); /** diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index 4a37f6cf87..b1bc84b534 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -1,6 +1,6 @@ import { UpdateFlagManager } from "../../UpdateFlagManager"; import { CloneMode } from "../../clone/enums/CloneMode"; -import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; /** * Particle curve. @@ -9,7 +9,6 @@ import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManag export class ParticleCurve { @ignoreClone private _updateManager = new UpdateFlagManager(); - @deepClone private _keys = new Array(); @ignoreClone private _typeArray: Float32Array; diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts index cffd1a57e1..4802e8956f 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -1,15 +1,13 @@ import { Color } from "@galacean/engine-math"; import { CloneMode } from "../../clone/enums/CloneMode"; -import { deepClone, ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; /** * Particle gradient. */ @defaultCloneMode(CloneMode.Deep) export class ParticleGradient { - @deepClone private _colorKeys: GradientColorKey[] = []; - @deepClone private _alphaKeys: GradientAlphaKey[] = []; @ignoreClone private _colorTypeArray: Float32Array; 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/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 4fc1d03196..ce6e8615b5 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"; @@ -41,11 +41,8 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule { @ignoreClone private _randomModeMacro: ShaderMacro; - @deepClone private _velocityX: ParticleCompositeCurve; - @deepClone private _velocityY: ParticleCompositeCurve; - @deepClone private _velocityZ: ParticleCompositeCurve; 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 c765e35c20..c1edb64417 100644 --- a/packages/core/src/particle/modules/shape/BaseShape.ts +++ b/packages/core/src/particle/modules/shape/BaseShape.ts @@ -2,7 +2,7 @@ import { BoundingBox, MathUtil, Matrix, Quaternion, Rand, Vector2, Vector3 } fro import { CloneMode } from "../../../clone/enums/CloneMode"; import { ParticleShapeType } from "./enums/ParticleShapeType"; import { UpdateFlagManager } from "../../../UpdateFlagManager"; -import { deepClone, ignoreClone, defaultCloneMode } from "../../../clone/CloneManager"; +import { ignoreClone, defaultCloneMode } from "../../../clone/CloneManager"; /** * Base class for all particle shapes. @@ -27,11 +27,8 @@ export abstract class BaseShape { 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(); 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/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..ae4de7f1a1 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"; @@ -24,7 +24,6 @@ export class Collider extends Component implements ICustomClone { _nativeCollider: ICollider; @ignoreClone protected _updateFlag: BoolUpdateFlag; - @deepClone protected _shapes: ColliderShape[] = []; protected _collisionLayerIndex: number = 0; 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 c1a299682c..02d9b6368b 100644 --- a/packages/core/src/physics/joint/Joint.ts +++ b/packages/core/src/physics/joint/Joint.ts @@ -4,7 +4,7 @@ import { Component } from "../../Component"; import { DependentMode, dependentComponents } from "../../ComponentsDependencies"; import { Entity } from "../../Entity"; import { TransformModifyFlags } from "../../Transform"; -import { deepClone, defaultCloneMode, ignoreClone } from "../../clone/CloneManager"; +import { defaultCloneMode, ignoreClone } from "../../clone/CloneManager"; import { CloneMode } from "../../clone/enums/CloneMode"; import { Collider } from "../Collider"; import { DynamicCollider } from "../DynamicCollider"; @@ -19,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; @@ -325,9 +323,7 @@ enum AnchorOwner { @defaultCloneMode(CloneMode.Deep) class JointColliderInfo { collider: Collider = null; - @deepClone anchor = new Vector3(); - @deepClone actualAnchor = new Vector3(); massScale: number = 1; inertiaScale: number = 1; 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..7e0e46a7fa 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -2,7 +2,7 @@ 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"; @@ -23,9 +23,7 @@ export abstract class ColliderShape implements ICustomClone { 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; 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/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts index a3bb7992b8..9623f24c3f 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -1,7 +1,8 @@ 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 { CloneManager, defaultCloneMode, ignoreClone } from "../clone/CloneManager"; +import { CloneMode } from "../clone/enums/CloneMode"; import { Texture } from "../texture/Texture"; import { ShaderMacro } from "./ShaderMacro"; import { ShaderMacroCollection } from "./ShaderMacroCollection"; @@ -12,6 +13,7 @@ import { ShaderPropertyType } from "./enums/ShaderPropertyType"; /** * Shader data collection,Correspondence includes shader properties data and macros data. */ +@defaultCloneMode(CloneMode.Deep) export class ShaderData implements IReferable, IClone { /** @internal */ @ignoreClone @@ -639,6 +641,14 @@ export class ShaderData implements IReferable, IClone { } } + /** + * @internal + * Deep-clone hook invoked by the clone gate; delegates to `cloneTo`. + */ + _cloneTo(target: ShaderData): void { + this.cloneTo(target); + } + /** * @internal */ diff --git a/packages/core/src/shader/state/BlendState.ts b/packages/core/src/shader/state/BlendState.ts index fcff6f2653..054749584e 100644 --- a/packages/core/src/shader/state/BlendState.ts +++ b/packages/core/src/shader/state/BlendState.ts @@ -2,7 +2,7 @@ import { IHardwareRenderer } from "@galacean/engine-design"; import { Color } from "@galacean/engine-math"; import { RenderStateElementMap } from "../../BasicResources"; import { GLCapabilityType } from "../../base/Constant"; -import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { defaultCloneMode } from "../../clone/CloneManager"; import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; @@ -75,10 +75,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/RenderState.ts b/packages/core/src/shader/state/RenderState.ts index 3a4f108b74..50c5e5e9ee 100644 --- a/packages/core/src/shader/state/RenderState.ts +++ b/packages/core/src/shader/state/RenderState.ts @@ -1,7 +1,7 @@ import { ShaderData, ShaderProperty } from ".."; import { RenderStateElementMap } from "../../BasicResources"; import { Engine } from "../../Engine"; -import { deepClone, defaultCloneMode } from "../../clone/CloneManager"; +import { defaultCloneMode } from "../../clone/CloneManager"; import { CloneMode } from "../../clone/enums/CloneMode"; import { RenderQueueType } from "../enums/RenderQueueType"; import { RenderStateElementKey } from "../enums/RenderStateElementKey"; @@ -16,16 +16,12 @@ import { StencilState } from "./StencilState"; @defaultCloneMode(CloneMode.Deep) export class RenderState { /** 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/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/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index a69cd846bf..5fb133245d 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -84,15 +84,10 @@ export class UICanvas extends Component implements IElement { 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; diff --git a/packages/ui/src/component/UIGroup.ts b/packages/ui/src/component/UIGroup.ts index b876e05ec4..a5a7504304 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"; @@ -25,11 +25,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..3d6fad5e91 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -42,7 +42,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 +69,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..c3593d2c1f 100644 --- a/packages/ui/src/component/UITransform.ts +++ b/packages/ui/src/component/UITransform.ts @@ -19,7 +19,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..9de06340dc 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -30,9 +30,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..cc17590b83 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -37,27 +37,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; /** diff --git a/packages/ui/src/component/interactive/UIInteractive.ts b/packages/ui/src/component/interactive/UIInteractive.ts index 6811819585..88531e3944 100644 --- a/packages/ui/src/component/interactive/UIInteractive.ts +++ b/packages/ui/src/component/interactive/UIInteractive.ts @@ -48,9 +48,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; From 00498a1d17caa14845cca01d38f012adbc35a7a0 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 23 Jun 2026 15:30:53 +0800 Subject: [PATCH 007/109] ci: shard codecov unit tests (4 shards) to avoid browser crash on full run Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1197f68014..2d005380ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,11 @@ jobs: codecov: runs-on: macos-latest + strategy: + matrix: + shard: [1/4, 2/4, 3/4, 4/4] + fail-fast: false + steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 @@ -70,7 +75,7 @@ jobs: - name: Build run: npm run build - name: Test - run: npm run coverage + run: npm run coverage -- --shard=${{ matrix.shard }} - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: From 1561b7242ae6bbc39dd2ad9bd61f33a6502237d1 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 23 Jun 2026 15:40:51 +0800 Subject: [PATCH 008/109] docs(clone): drop CLONE_OPT_IN_AUDIT.md; design moved to PR description Co-Authored-By: Claude Opus 4.8 --- CLONE_OPT_IN_AUDIT.md | 212 ------------------------------------------ 1 file changed, 212 deletions(-) delete mode 100644 CLONE_OPT_IN_AUDIT.md diff --git a/CLONE_OPT_IN_AUDIT.md b/CLONE_OPT_IN_AUDIT.md deleted file mode 100644 index ac2295111d..0000000000 --- a/CLONE_OPT_IN_AUDIT.md +++ /dev/null @@ -1,212 +0,0 @@ -# Clone Opt-in Migration Audit - -Capture-audit for the opt-out → opt-in (`@property`) flip. Goal: find every field that is -**currently cloned but undecorated**, which would silently stop cloning once only `@property` -fields are walked. - -## Status - -- ✅ Mechanism: `CloneManager` flipped to opt-in (walks the `@property` field set; HOW type-driven). - `@property` added; `@deepClone`/`@assignmentClone` are temporary bridges to it, `@ignoreClone` a - no-op bridge — so all 64 existing files keep compiling during migration. -- ✅ Field pass: 143 `@property` added (per-file counts match this audit exactly; no stacked - decorators). `Skin.name` converted from ctor-param to a `@property` field + parameterless ctor. -- ✅ Invariants verified: every object-typed `@assignmentClone` is Assignment-default (Font / - AnimatorController / AudioClip / SubFont = ReferResource); no object-typed `@deepClone` is on an - Assignment/Remap type → the type-driven HOW flip is behavior-exact. Decision D auto-satisfied - (`_camera` = Remap, `_renderTarget` = Assignment via type default — no special handling needed). -- ✅ `tsc` clean (only pre-existing module-resolution noise), prettier clean. -- ⏳ Runtime verification: needs `pnpm build` + clone tests (can't run on this src-checkout worktree). -- ✅ Cosmetic cleanup DONE: 63 files converted (`@deepClone`/`@assignmentClone` → `@property`, - `@ignoreClone` deleted); bridge decorators + `CloneMode.Ignore` removed. Source compiles clean, - prettier clean. 313 `@property` decorators total. -- ✅ Spec tests rewritten for opt-in: `CloneUtils.test.ts` + `Transform.test.ts` fixtures now mark - fields `@property`; the "undecorated/type-inference" + "decorator-wins" blocks were replaced with an - "Opt-in: marked vs unmarked fields" block (incl. "unmarked field is NOT cloned"); the redundant - "Undecorated array/object" blocks were dropped; titles updated. 0 legacy decorator references remain. -- ✅ Runtime verified: `pnpm build` + browser tests — **986 passing, 0 failing** (core 826 incl. - physics/postprocess/materials/particle/all components; ui 64; loader 96 incl. PrefabResource + - SceneFormatV2). Run per-dir/per-file: the vitest browser runner hits a websocket payload cap - (`WS_ERR_UNSUPPORTED_MESSAGE_LENGTH`) if too many browser test files run in one process — infra, not a - failure. (It also surfaces if a clone assertion fails on Entity/Component objects → huge diff overflows - the socket; that's how the UIInteractive Transition gap first showed up.) -- ✅ `PrefabResource.test.ts` (prefab `instantiate()` = `_root.clone()`): 3 fixture scripts used - undecorated fields → didn't carry on instantiate. Decorated `DiceScript.skinMesh/numMesh`, - `OverrideCallScript.value/receivedArgs`, `EntityRefScript.target`. Reinforces the unified rule: a field - serialized into a prefab is by definition `@property`. - -## Gap-fill (post-audit; found via the test suite + a static `@property`-field-type sweep) - -The first audit discovered files **by their decorators**, so cloneable classes with *only undecorated* -state (relying on opt-out auto-clone) were invisible. Closed: -- **Decorator-less Component subclasses:** physics shapes (`Sphere`/`Capsule`/`Mesh`ColliderShape), - joints (`SpringJoint`; `FixedJoint`/`StaticCollider` have no own state), `PointLight.distance`, - `Probe`, particle shapes (`Sphere`/`Cone`/`Circle`/`Hemisphere`Shape). -- **Plain deep-clone targets** reached via now-`@property` object fields: `PhysicsMaterial`, - `PostProcessEffect`(+`Bloom`/`Tonemapping`) and `PostProcessEffectParameter` (the value wrapper), - `Transition`(+ `_target`/`_interactive` — a sub-object whose owner `UIInteractive` never re-links it). -- **Confirmed dormant (no action):** `DepthState`/`StencilState`/`RasterState`/`RenderTargetBlendState` - — only held by `ShaderPass` (asset) / `Engine` (singleton), never reached from a `@property` field. -- **Pre-existing, out of scope (flagged):** `MeshColliderShape` clone doesn't rebuild its native shape / - has an unbalanced `_mesh` refcount (present under opt-out too); `ShaderData` deep-clone copies nothing - (all fields were `@ignoreClone`; behavior preserved). - -## Model - -- **Today (opt-out):** `ComponentCloner` does `for (k in source) cloneProperty(...)` — EVERY - enumerable instance field is cloned unless `@ignoreClone`. `@deepClone`/`@assignmentClone` only - pick HOW. -- **Target (opt-in):** only `@property` fields are cloned. HOW stays type-driven - (`type + @defaultCloneMode`): Entity/Component → Remap, ReferResource/flyweights → Assignment, - else → Deep. - -## Mechanical conversion rules - -1. `@ignoreClone` → **delete** the decorator (unmarked = not cloned; behavior preserved). ~347 sites. -2. `@deepClone` / `@assignmentClone` → **`@property`** (still cloned; HOW now type-driven). ~171 sites. -3. Undecorated-but-cloned fields holding real state → **add `@property`** (the regression risks - below). ~141 sites. -4. Undecorated transient/derived/back-pointer fields → leave unmarked (safe to drop). - -`_cloneTo` always runs after the field walk, so fields it re-establishes (e.g. `MeshRenderer._mesh`, -`SpriteRenderer._sprite`, `UICanvas._renderMode`, `UITransform._size/_pivot`) need NO `@property`. - -The deep-clone Construct stage calls `new ctor()`, so constructors DO run on clones — function-typed -fields (bound handlers) are re-established by the ctor and correctly skipped by the function fast-path. - ---- - -## REGRESSION RISKS — undecorated fields that need `@property` (≈141) - -### core -**Camera** (19): `enableFrustumCulling`, `clearFlags`, `cullingMask`, `postProcessMask`, -`depthTextureMode`, `opaqueTextureDownsampling`, `antiAliasing`, `isAlphaOutputRequired`, `_priority`, -`_isCustomViewMatrix`, `_isCustomProjectionMatrix`, `_fieldOfView`, `_orthographicSize`, -`_customAspectRatio`, `_opaqueTextureEnabled`, `_enableHDR`, `_enablePostProcess`, `_msaaSamples`, -`_renderTarget` *(see Decision D — also breaks `_cloneTo` refcount if dropped)* -**VirtualCamera** (3): `isOrthographic`, `nearClipPlane`, `farClipPlane` -**Renderer** (1): `castShadows` - -### lighting + mesh -**Light** (6): `cullingMask`, `shadowType`, `shadowBias`, `shadowNormalBias`, `shadowNearPlane`, -`_shadowStrength` -**DirectLight** (1): `shadowNearPlaneOffset` -**SpotLight** (3): `distance`, `angle`, `penumbra` -**MeshRenderer** (1): `_enableVertexColor` -**Skin** (3): `_rootBone`, `name` *(ctor-param prop — see Decision A)*, `joints` (deprecated) - -### physics -**Collider** (1): `_collisionLayerIndex` -**DynamicCollider** (13): `_linearDamping`, `_angularDamping`, `_mass`, `_maxAngularVelocity`, -`_maxDepenetrationVelocity`, `_solverIterations`, `_useGravity`, `_isKinematic`, `_constraints`, -`_collisionDetectionMode`, `_sleepThreshold`, `_automaticCenterOfMass`, `_automaticInertiaTensor` -**CharacterController** (3): `_stepOffset`, `_nonWalkableMode`, `_slopeLimit` -**Joint** (3): `_force`, `_torque`, `_automaticConnectedAnchor` -**JointColliderInfo** (3): `collider` *(UNCERTAIN — see below)*, `massScale`, `inertiaScale` -**HingeJoint** (2): `_hingeFlags`, `_useSpring` -**JointLimits** (5): `_max`, `_min`, `_contactDistance`, `_stiffness`, `_damping` -**JointMotor** (4): `_targetVelocity`, `_forceLimit`, `_gearRatio`, `_freeSpin` -**ColliderShape** (4): `_material`, `_isTrigger`, `_contactOffset`, `isSceneQuery` - -### particle -**ParticleRenderer** (4): `velocityScale`, `lengthScale`, `_renderMode`, `_mesh` -**ParticleGenerator** (1): `useAutoRandomSeed` *(+ `_randomSeed` UNCERTAIN — Decision C)* -**MainModule** (9): `duration`, `isLoop`, `startRotation3D`, `flipRotation`, `simulationSpeed`, -`scalingMode`, `playOnEnabled`, `_startSize3D`, `_simulationSpace` -**Burst** (3): `time`, `_cycles`, `_repeatInterval` *(+ `count` already @deepClone; see Decision A)* -**ForceOverLifetimeModule** (1): `_space` -**LimitVelocityOverLifetimeModule** (5): `_separateAxes`, `_dampen`, `_multiplyDragByParticleSize`, -`_multiplyDragByParticleVelocity`, `_space` -**NoiseModule** (6): `_scrollSpeed`, `_separateAxes`, `_frequency`, `_octaveCount`, -`_octaveIntensityMultiplier`, `_octaveFrequencyMultiplier` -**RotationOverLifetimeModule** (1): `separateAxes` -**SizeOverLifetimeModule** (1): `_separateAxes` -**VelocityOverLifetimeModule** (1): `_space` -**TextureSheetAnimationModule** (2): `type`, `cycleCount` -**ParticleCompositeCurve** (3): `_mode`, `_constantMin`, `_constantMax` -**ParticleCompositeGradient** (1): `mode` -**CurveKey** (2): `_time`, `_value` -**GradientColorKey** (2): `_time`, `_color` *(undecorated → currently shallow-shared; should be deep)* -**GradientAlphaKey** (2): `_time`, `_alpha` -**ParticleGeneratorModule** (1): `_enabled` ← **base class; the per-module on/off flag — affects ALL modules** -**BaseShape** (2): `_enabled`, `_randomDirectionAmount` - -### shader-state / post / trail -**BlendState** (1): `alphaToCoverage` -**RenderState** (1): `renderQueueType` -**PostProcess** (4): `layer`, `blendDistance`, `_priority`, `_isGlobal` -**TrailRenderer** (3): `emitting`, `minVertexDistance`, `_time` - -### animation -**Animator** (1): `cullingMode` - -### ui -**UICanvas** (1): `_camera` *(see Decision D — currently deep-cloned, NOT handled by `_cloneTo`)* -**UITransform** (8): `_alignLeft`, `_alignRight`, `_alignCenter`, `_alignTop`, `_alignBottom`, -`_alignMiddle`, `_horizontalAlignment`, `_verticalAlignment` - ---- - -## Undecorated fields safe to DROP (transient / derived / back-pointer) - -- **core:** `Component._entity`, `EngineObject._pendingDestroy`, `EngineObject._destroyed`, - `Transform._parentTransformCache`, `Camera._isProjectionDirty`, `Camera._isInvProjMatDirty`, - `Renderer._transformEntity` -- **particle:** `ParticleRenderer._currentRenderModeMacro`, `ParticleRenderer._supportInstancedArrays`, - `ParticleGenerator._currentParticleCount`, `ParticleGenerator._renderer`, `MainModule._tempVector40`, - `EmissionModule._frameRateTime`, `EmissionModule._currentBurstIndex`, `HingeJoint._angle`, - `HingeJoint._velocity`, `ParticleCurve._typeArrayDirty`, `ParticleGradient._colorTypeArrayDirty`, - `ParticleGradient._alphaTypeArrayDirty`, `GradientColorKey._onValueChanged`, - `GradientAlphaKey._onValueChanged` -- **physics:** `ColliderShape._collider` -- **ui (back-pointers, lazy-resolved):** `UICanvas._rootCanvas`, `UICanvas._cameraObserver`, - `UIGroup._group`, `UIGroup._rootCanvas`, `UIRenderer._rootCanvas`, `UIRenderer._group`, - `UIInteractive._rootCanvas`, `UIInteractive._group` - -(All other transient fields already carry `@ignoreClone` → just delete the decorator.) - ---- - -## Decisions needed - -**A. Parameterless-constructor contract violated by `Burst`.** -Clone Construct does `new ctor()`. `Burst` only has `constructor(time, count)`. Today the field-walk -backfills, but the clone is constructed with `undefined` args first. Recommend: make ctor params -optional so every cloneable class is parameterless-constructible (the contract we documented). Same -check for any other arg-required cloneable class (`Skin(name)` is similar). - -**B. Particle "value types" have NO `copyFrom`.** -`ParticleCompositeCurve` / `ParticleCurve` / `ParticleGradient` / `ParticleCompositeGradient` / -`CurveKey` / gradient keys are field-cloned, not copyFrom-cloned. So all their state fields need -`@property` (listed above). No fork — just noting they're not the value-type fast path. - -**C. `ParticleGenerator._randomSeed`.** -Moot when `useAutoRandomSeed` (re-randomized on play), but a user-set custom seed is authored state. -Recommend KEEP (`@property`) for reproducibility. - -**D. References currently DEEP-cloned by accident: `UICanvas._camera`, `Camera._renderTarget`.** -Undecorated today → deep-cloned, which is wrong for a Camera/RenderTarget reference. The flip is the -chance to fix HOW: a Camera reference should Remap (or share), a RenderTarget should share (Assignment) -— not deep-clone. Recommend `@property` + correct type-driven HOW, not preserve the buggy deep clone. -`Camera._renderTarget` must be kept (its `_cloneTo` refcount depends on the reference being present). - -**E. ReferResource scope (`Sprite` etc.).** -`Sprite` is a `ReferResource`, NOT walked by `ComponentCloner` — it has its own manual `clone()`. Its -undecorated fields are moot for the component flip. Recommend: scope opt-in to Component clone now; -unify ReferResource serialization later. - -**F. UI back-pointers undecorated (not even `@ignoreClone`).** -`_rootCanvas`/`_group` on UICanvas/UIGroup/UIRenderer/UIInteractive are cloned today (wastefully) but -safe to drop (lazy re-resolve). Minor pre-existing sloppiness; the flip fixes it for free. - ---- - -## Suggested staging - -1. **Mechanism:** add `@property` decorator; generalize the per-class registry into a property-field - set; switch `cloneComponent`/`cloneProperty` to iterate the set instead of `for k in source`. -2. **Migrate, package by package** (core → physics → particle → 2d/anim → shader/post/trail → ui), - verifying after each: convert `@deep`/`@assign` → `@property`, add `@property` to the risk fields - above, delete `@ignoreClone`. -3. **Verify:** instrument the cloner to diff the runtime-cloned (class, field) set before vs after — it - must match except the intentional drops. From d196914f4cef696ffef309578b03777e4ccc455d Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 23 Jun 2026 16:34:52 +0800 Subject: [PATCH 009/109] ci: split codecov into 8 shards (vitest browser WebGL resource ceiling) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d005380ee..e829f12e29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: strategy: matrix: - shard: [1/4, 2/4, 3/4, 4/4] + shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8] fail-fast: false steps: From 3cdd18cea8bee475e977a09a432a3f91016da225 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 23 Jun 2026 21:17:39 +0800 Subject: [PATCH 010/109] test(clone): regression for texture refCount balance on clone/destroy Co-Authored-By: Claude Opus 4.8 --- tests/src/core/CloneTextureRefCount.test.ts | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/src/core/CloneTextureRefCount.test.ts diff --git a/tests/src/core/CloneTextureRefCount.test.ts b/tests/src/core/CloneTextureRefCount.test.ts new file mode 100644 index 0000000000..830746bfa7 --- /dev/null +++ b/tests/src/core/CloneTextureRefCount.test.ts @@ -0,0 +1,32 @@ +import { MeshRenderer, Entity, 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("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(); + }); +}); From b8e754ced4140b1da0e275d461eb6dc0a9c060ad Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 23 Jun 2026 21:31:47 +0800 Subject: [PATCH 011/109] ci: revert codecov sharding experiment (let GitHub run the original flow) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e829f12e29..1197f68014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,11 +55,6 @@ jobs: codecov: runs-on: macos-latest - strategy: - matrix: - shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8] - fail-fast: false - steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 @@ -75,7 +70,7 @@ jobs: - name: Build run: npm run build - name: Test - run: npm run coverage -- --shard=${{ matrix.shard }} + run: npm run coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: From 2139275570b26cf10cd83ad0e54ae7ce28469b04 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 2 Jul 2026 18:11:16 +0800 Subject: [PATCH 012/109] refactor(clone): decouple ref counting from the clone gate and unify remap via identity map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assignment is now a plain reference share: the gate never touches refCount. Ref-count ownership belongs to each class's own logic (_cloneTo hooks / setters), balanced by that class's destroy path. Fixes double-counting on Camera/Animator/AudioSource clones, Shader leak via _replacementShader, and permanent leaks for user-script-held resources. - Field decorators are highest priority at all depths: the ComponentCloner top-level remap special case is removed; Entity/Component remap unified through the pre-populated cloneMap (O(1)); CloneUtils path-walk deleted. @deepClone on an Entity/Component ref warns and falls back to remap. - Entity.clone() builds the tree and registers identity pairs in one walk; srcRoot/targetRoot threading removed — _cloneTo hooks receive cloneMap. - Container classification centralized (single _isContainer); DataView clones as bytes (was: crash via generic object branch); functions in containers are shared instead of dropped to undefined, while constructor-rebound top-level handlers keep the clone's own binding. - Register ColliderShape / ui Transition as @defaultCloneMode(Deep) so cloned colliders/interactives own independent instances; restore @ignoreClone + setter pattern for TextRenderer/Text fonts and MeshColliderShape mesh; SpriteTransition acquires state-sprite refs on clone. - shallowClone decorator warns on use; dead copyProperty/copyProperties removed; CloneMode exported; orphan imports cleaned. - Tests: decorator-priority semantics updated; new regressions for function fields, DataView, refCount balance (RT/controller/font/sprite-transition), collider-shape and transition instance independence. Co-Authored-By: Claude Fable 5 --- packages/core/src/2d/text/TextRenderer.ts | 1 + packages/core/src/Component.ts | 8 - packages/core/src/Entity.ts | 61 ++--- packages/core/src/Signal.ts | 34 +-- packages/core/src/clone/CloneManager.ts | 148 +++++----- packages/core/src/clone/CloneUtils.ts | 46 ---- packages/core/src/clone/ComponentCloner.ts | 30 +-- packages/core/src/index.ts | 2 +- .../core/src/physics/shape/ColliderShape.ts | 4 +- .../src/physics/shape/MeshColliderShape.ts | 15 ++ packages/ui/src/component/UICanvas.ts | 2 - packages/ui/src/component/UIRenderer.ts | 2 - packages/ui/src/component/UITransform.ts | 11 +- packages/ui/src/component/advanced/Image.ts | 1 - packages/ui/src/component/advanced/Text.ts | 2 +- .../component/interactive/UIInteractive.ts | 10 +- .../transition/SpriteTransition.ts | 15 ++ .../interactive/transition/Transition.ts | 3 +- tests/src/core/CloneTextureRefCount.test.ts | 64 ++++- tests/src/core/CloneUtils.test.ts | 254 +++++++++++++++++- tests/src/core/Signal.test.ts | 23 +- tests/src/core/physics/ColliderShape.test.ts | 34 +++ tests/src/ui/UIInteractive.test.ts | 65 ++++- 23 files changed, 571 insertions(+), 264 deletions(-) delete mode 100644 packages/core/src/clone/CloneUtils.ts diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index c2911415ff..65fce872fd 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -46,6 +46,7 @@ export class TextRenderer extends Renderer implements ITextRenderer { private _height = 0; @ignoreClone private _localBounds = new BoundingBox(); + @ignoreClone private _font: Font = null; private _fontSize = 24; private _fontStyle = FontStyle.None; diff --git a/packages/core/src/Component.ts b/packages/core/src/Component.ts index 13d0cb2235..1544e1e063 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 { defaultCloneMode, ignoreClone } from "./clone/CloneManager"; -import { CloneUtils } from "./clone/CloneUtils"; import { CloneMode } from "./clone/enums/CloneMode"; import { Entity } from "./Entity"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; @@ -155,13 +154,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 87b3ad79c8..1ab32d1c3c 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -11,7 +11,6 @@ import { UpdateFlagManager } from "./UpdateFlagManager"; import { ReferResource } from "./asset/ReferResource"; import { EngineObject } from "./base"; import { defaultCloneMode } from "./clone/CloneManager"; -import { CloneUtils } from "./clone/CloneUtils"; import { ComponentCloner } from "./clone/ComponentCloner"; import { CloneMode } from "./clone/enums/CloneMode"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; @@ -424,10 +423,9 @@ export class Entity extends EngineObject { * @returns Cloned entity */ clone(): Entity { - const cloneEntity = this._createCloneEntity(); const cloneMap = new Map(); - this._registerCloneMap(this, cloneEntity, cloneMap); - this._parseCloneEntity(this, cloneEntity, this, cloneEntity, cloneMap); + const cloneEntity = this._createCloneEntity(cloneMap); + this._parseCloneEntity(this, cloneEntity, cloneMap); return cloneEntity; } @@ -439,13 +437,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 */ @@ -454,7 +445,14 @@ export class Entity extends EngineObject { this._templateResource = templateResource; } - private _createCloneEntity(): Entity { + /** + * Build the clone's entity/component tree and register every source entity/component to its + * clone in the identity map, so the value-copy pass (`_parseCloneEntity`) can remap references — + * including ones nested in arrays / maps / objects — through the clone gate. Registration + * completes for the whole subtree before any value is copied, because a component anywhere in + * the tree may reference an entity anywhere else in it. + */ + private _createCloneEntity(cloneMap: Map): Entity { const componentConstructors = Entity._tempComponentConstructors; const components = this._components; for (let i = 0, n = components.length; i < n; i++) { @@ -462,6 +460,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; @@ -472,47 +475,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; } - /** - * Register every source Entity / Component to its clone in the identity map, so the value-copy - * pass (`_parseCloneEntity`) can remap references — including ones nested in arrays / maps / objects — - * through the clone gate. Must complete for the whole subtree before any value is copied, because a - * component anywhere in the tree may reference an entity anywhere else in it. - */ - private _registerCloneMap(src: Entity, target: Entity, cloneMap: Map): void { - cloneMap.set(src, target); - const srcComponents = src._components; - const targetComponents = target._components; - for (let i = 0, n = srcComponents.length; i < n; i++) { - cloneMap.set(srcComponents[i], targetComponents[i]); - } - const srcChildren = src._children; - const targetChildren = target._children; - for (let i = 0, n = srcChildren.length; i < n; i++) { - this._registerCloneMap(srcChildren[i], targetChildren[i], cloneMap); - } - } - - 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/Signal.ts b/packages/core/src/Signal.ts index 1671fd55e8..3839de7aed 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,6 +1,4 @@ import { Component } from "./Component"; -import { Entity } from "./Entity"; -import { CloneUtils } from "./clone/CloneUtils"; import { defaultCloneMode, ignoreClone } from "./clone/CloneManager"; import { CloneMode } from "./clone/enums/CloneMode"; import { SafeLoopArray } from "./utils/SafeLoopArray"; @@ -124,38 +122,34 @@ export class Signal { /** * @internal - * Clone listeners to target signal, remapping entity/component references. + * Clone listeners to target signal, remapping entity/component references through the + * cloned subtree's identity map (references outside the subtree are kept as-is). */ - _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); - } else { - clonedArgs[i] = arg; - } + // Only Entity/Component references (Remap types) are remapped; other objects are shared + // deterministically (looking them up in the identity map would depend on field-walk order). + clonedArgs[i] = + arg instanceof Object && (arg)._defaultCloneMode === CloneMode.Remap ? (cloneMap.get(arg) ?? arg) : arg; } return clonedArgs; } diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index ab84a927ba..10638281f3 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -42,8 +42,13 @@ export function ignoreClone(target: Object, propertyKey: string): void { /** * @deprecated Shallow clone is no longer a distinct mode; treated as deep clone. + * Use `@deepClone`, or `@assignmentClone` to share the reference. */ export function shallowClone(target: Object, propertyKey: string): void { + Logger.warn( + `@shallowClone is deprecated and now behaves as @deepClone ` + + `(field "${propertyKey}" of ${target.constructor?.name}); use @deepClone or @assignmentClone instead.` + ); CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); } @@ -56,7 +61,7 @@ export function shallowClone(target: Object, propertyKey: string): void { * Built-in defaults: * - Entity / Component → `CloneMode.Remap` * - ReferResource (Texture, Mesh, Material, etc.) → `CloneMode.Assignment` - * - Value-semantic config objects (RenderState, ParticleModule, etc.) → `CloneMode.Deep` + * - Value-semantic config objects (RenderState, ParticleModule, ColliderShape, etc.) → `CloneMode.Deep` * * @param mode - The clone mode applied to instances of the decorated type */ @@ -73,10 +78,16 @@ export function defaultCloneMode(mode: CloneMode) { * Opt-out model: all enumerable fields of an object are cloned unless marked `@ignoreClone`. * HOW each field value is cloned depends on the value's runtime type (`@defaultCloneMode`): * - primitive / null / undefined → assign by value. - * - function → skipped (transient; the clone's constructor re-establishes bound handlers). + * - function → keep the clone's own binding when its slot already holds one (constructor-rebound + * handlers), otherwise share the reference (container elements have no own slot value). * - Remap (Entity / Component) → resolve to the clone via the identity map. * - Assignment (ReferResource / unknown types without @defaultCloneMode) → share the reference. - * - Deep (@defaultCloneMode(Deep) / copyFrom types) → recursively deep clone. + * - Deep (@defaultCloneMode(Deep) / copyFrom types / containers) → recursively deep clone. + * + * The gate never touches reference counting: `Assignment` is a plain reference share. Ref-count + * ownership acquired by a clone belongs to the owner class's own logic (`_cloneTo` hooks and + * setters, balanced by that class's destroy path), e.g. `Camera._cloneTo`, `Renderer._cloneTo`, + * `ShaderData.cloneTo`. * * Deep clone lifecycle (3-stage): * 1. Construct — reuse the clone's existing slot value if same type, else `new ctor()`. @@ -131,74 +142,60 @@ export class CloneManager { return modes; } - static copyProperty(source: Object, target: Object, k: string | number, cloneMap: Map): void { - target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap); - } - /** * @internal * Clone gate — decides how to clone one value based on its type. */ - static _cloneValue( - value: any, - reuse: any, - cloneMap: Map, - fieldMode?: CloneMode, - srcRoot?: any, - targetRoot?: any - ): any { + static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { if (!(value instanceof Object)) return value; - if (typeof value === "function") return reuse; + // Functions: an explicit field decorator (@assignmentClone/@deepClone) shares the reference; + // by default keep the clone's own binding when its slot already holds one (constructor-rebound + // handlers), otherwise share (container elements / null-preset slots have none). + if (typeof value === "function") { + return fieldMode !== undefined ? value : typeof reuse === "function" ? reuse : value; + } // Mode priority: field decorator (highest) → container default deep → type's `@defaultCloneMode` → Assignment. let cloneMode = fieldMode; if (cloneMode === undefined) { - if ( - Array.isArray(value) || - value instanceof Map || - value instanceof Set || - ArrayBuffer.isView(value) || - value.constructor === Object - ) { - cloneMode = CloneMode.Deep; - } else { - cloneMode = (value)._defaultCloneMode ?? CloneMode.Assignment; - } + cloneMode = CloneManager._isContainer(value) + ? CloneMode.Deep + : ((value)._defaultCloneMode ?? CloneMode.Assignment); + } else if (cloneMode === CloneMode.Deep && (value)._defaultCloneMode === CloneMode.Remap) { + // Entity/Component instances cannot be deep cloned (engine-bound constructors, live scene + // state); a @deepClone-decorated reference falls back to remap for reference correctness. + Logger.warn( + `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` + ); + cloneMode = CloneMode.Remap; } if (cloneMode === CloneMode.Ignore) return reuse; - if (cloneMode === CloneMode.Assignment) { - const reusedResource = <{ _addReferCount?(count: number): void; refCount?: number }>reuse; - if (reusedResource?._addReferCount) { - const presetRefCount = reusedResource.refCount; - presetRefCount !== undefined && - presetRefCount <= 0 && - Logger.error( - `CloneManager: the clone's preset ${reuse.constructor.name} holds no owned reference; ` + - `a constructor presetting a ref-counted resource must acquire it (assign via its setter or an explicit +1).` - ); - reusedResource._addReferCount(-1); - } - (<{ _addReferCount?(count: number): void }>value)._addReferCount?.(1); - return value; - } - if (cloneMode === CloneMode.Remap) { - return cloneMap.get(value) ?? value; - } - return CloneManager._deepClone(value, reuse, cloneMap, srcRoot, targetRoot); + if (cloneMode === CloneMode.Assignment) return value; + if (cloneMode === CloneMode.Remap) return cloneMap.get(value) ?? value; + return CloneManager._deepClone(value, reuse, cloneMap); + } + + /** + * Container-shape test — the single classification point shared by the gate and `_deepClone`. + * Invariant: every shape this returns true for MUST have a dedicated branch in `_deepClone` + * (ArrayBuffer view → byte copy, Array/Map/Set → per-element, plain object → field walk). + */ + private static _isContainer(value: Object): boolean { + return ( + Array.isArray(value) || + value instanceof Map || + value instanceof Set || + ArrayBuffer.isView(value) || + value.constructor === Object + ); } /** - * Deep-clone one object graph. Cycles / shared sub-graphs dedup through the identity map. - * `srcRoot`/`targetRoot` are threaded to `_cloneTo` hooks that remap entity/component references. + * Deep-clone one object graph. Cycles / shared sub-graphs dedup through the identity map, + * which also remaps Entity/Component references nested anywhere in the graph. */ - private static _deepClone( - value: any, - reuse: any, - cloneMap: Map, - srcRoot?: any, - targetRoot?: any - ): any { + private static _deepClone(value: any, reuse: any, cloneMap: Map): any { const existing = cloneMap.get(value); if (existing) return existing; @@ -208,12 +205,22 @@ export class CloneManager { reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); cloneMap.set(value, dst); (dst).copyFrom(value); - (value)._cloneTo?.(dst, srcRoot, targetRoot); + (value)._cloneTo?.(dst, cloneMap); return dst; } - // Typed array — buffer copy. - if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + // ArrayBuffer views — byte copy (covers every view `_isContainer` routes here, incl. DataView). + if (ArrayBuffer.isView(value)) { + if (value instanceof DataView) { + const src = value; + if (reuse instanceof DataView && reuse.byteLength === src.byteLength) { + new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( + new Uint8Array(src.buffer, src.byteOffset, src.byteLength) + ); + return reuse; + } + return new DataView(src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength)); + } const src = value; if (reuse && reuse.constructor === src.constructor && (reuse).length === src.length) { (reuse).set(src); @@ -227,7 +234,7 @@ export class CloneManager { const dst = new Array(value.length); cloneMap.set(value, dst); for (let i = 0, n = value.length; i < n; i++) { - dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap, undefined, srcRoot, targetRoot); + dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); } return dst; } @@ -237,10 +244,7 @@ export class CloneManager { const dst = new Map(); cloneMap.set(value, dst); value.forEach((v, key) => { - dst.set( - CloneManager._cloneValue(key, undefined, cloneMap, undefined, srcRoot, targetRoot), - CloneManager._cloneValue(v, undefined, cloneMap, undefined, srcRoot, targetRoot) - ); + dst.set(CloneManager._cloneValue(key, undefined, cloneMap), CloneManager._cloneValue(v, undefined, cloneMap)); }); return dst; } @@ -249,7 +253,7 @@ export class CloneManager { if (value instanceof Set) { const dst = new Set(); cloneMap.set(value, dst); - value.forEach((v) => dst.add(CloneManager._cloneValue(v, undefined, cloneMap, undefined, srcRoot, targetRoot))); + value.forEach((v) => dst.add(CloneManager._cloneValue(v, undefined, cloneMap))); return dst; } @@ -261,24 +265,14 @@ export class CloneManager { for (const key in value) { const fieldMode = fieldModes.get(key); if (fieldMode === CloneMode.Ignore) continue; - dst[key] = CloneManager._cloneValue(value[key], dst[key], cloneMap, fieldMode, srcRoot, targetRoot); + dst[key] = CloneManager._cloneValue(value[key], dst[key], cloneMap, fieldMode); } - (value)._cloneTo?.(dst, srcRoot, targetRoot); + (value)._cloneTo?.(dst, cloneMap); return dst; } /** - * Copy every enumerable property of `source` into `target`, deep-cloning each value through - * the type-driven gate. - */ - static copyProperties(source: Object, target: Object, cloneMap: Map): void { - for (let k in source) { - CloneManager.copyProperty(source, target, k, cloneMap); - } - } - - /** - * Deep clone all fields of source into target (bypasses ignore check). + * Deep clone all enumerable fields of source into target through the clone gate. */ static deepCloneObject(source: Object, target: Object, cloneMap: Map): void { for (let k in source) { 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 257724ce13..81e9a23b9e 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -1,5 +1,4 @@ import { Component } from "../Component"; -import { Entity } from "../Entity"; import { CloneManager } from "./CloneManager"; import { CloneMode } from "./enums/CloneMode"; @@ -15,12 +14,10 @@ export interface ICustomClone { readonly _defaultCloneMode?: CloneMode; /** * @internal + * Post-clone hook. `cloneMap` maps every source entity/component (and deep-cloned object) + * in the cloned subtree to its clone, for remapping references. */ - _remap?(srcRoot: Entity, targetRoot: Entity): Object; - /** - * @internal - */ - _cloneTo?(target: ICustomClone, srcRoot?: Entity, targetRoot?: Entity): void; + _cloneTo?(target: ICustomClone, cloneMap?: Map): void; /** * @internal */ @@ -32,27 +29,16 @@ export class ComponentCloner { * Clone component (opt-out: all fields cloned except @ignoreClone). * @param source - Clone source * @param target - Clone target + * @param cloneMap - Identity map of the cloned subtree (source entity/component → clone) */ - static cloneComponent( - source: Component, - target: Component, - srcRoot: Entity, - targetRoot: Entity, - deepInstanceMap: Map - ): void { + static cloneComponent(source: Component, target: Component, cloneMap: Map): void { const fieldModes = CloneManager.getFieldModes(source.constructor); for (const k in source) { - const sourceProperty = source[k]; - // Entity/Component references are always remapped (reference correctness, above field ignore). - if (sourceProperty instanceof Object && (sourceProperty)._remap) { - target[k] = (sourceProperty)._remap(srcRoot, targetRoot); - continue; - } const fieldMode = fieldModes.get(k); if (fieldMode === CloneMode.Ignore) continue; - // Field decorator (highest) → value-shape / type default. - target[k] = CloneManager._cloneValue(sourceProperty, target[k], deepInstanceMap, fieldMode, srcRoot, targetRoot); + // Field decorator (highest) → value-shape / type default (Entity/Component remap via the map). + target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode); } - ((source as unknown))._cloneTo?.(target, srcRoot, targetRoot); + ((source as unknown))._cloneTo?.(target, cloneMap); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be483eccbd..308c4751be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,7 +70,7 @@ export * from "./env-probe/index"; export * from "./shader/index"; export * from "./Layer"; export * from "./clone/CloneManager"; -export { CloneUtils } from "./clone/CloneUtils"; +export * from "./clone/enums/CloneMode"; export * from "./renderingHardwareInterface/index"; export * from "./physics/index"; export * from "./Utils"; diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 7e0e46a7fa..0e8cc7f202 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -2,14 +2,16 @@ import { IColliderShape } from "@galacean/engine-design"; import { PhysicsMaterial } from "../PhysicsMaterial"; import { Vector3 } from "@galacean/engine-math"; import { Collider } from "../Collider"; -import { ignoreClone } from "../../clone/CloneManager"; +import { defaultCloneMode, ignoreClone } from "../../clone/CloneManager"; import { ICustomClone } from "../../clone/ComponentCloner"; +import { CloneMode } from "../../clone/enums/CloneMode"; import { Engine } from "../../Engine"; import { ColliderShapeChangeFlag } from "../enums/ColliderShapeChangeFlag"; /** * Abstract class for collider shapes. */ +@defaultCloneMode(CloneMode.Deep) export abstract class ColliderShape implements ICustomClone { private static _idGenerator: number = 0; diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts index 51a46ab0e0..f434ead5be 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,11 +11,15 @@ 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; + @ignoreClone private _isShapeAttached = false; /** @@ -98,6 +103,16 @@ export class MeshColliderShape extends ColliderShape { return super.getClosestPoint(point, outClosestPoint); } + /** + * @internal + */ + override _cloneTo(target: MeshColliderShape): void { + // Runtime state (@ignoreClone) is rebuilt through the setter: refCount +1, mesh-data + // extraction and native shape creation (using the already-copied isConvex/cookingFlags). + target.mesh = this._mesh; + super._cloneTo(target); + } + /** * @internal */ diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index 5fb133245d..c5b6ec6413 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"; diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index 3d6fad5e91..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"; diff --git a/packages/ui/src/component/UITransform.ts b/packages/ui/src/component/UITransform.ts index c3593d2c1f..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"; diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index 9de06340dc..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"; diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index cc17590b83..5c87bc1c0a 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"; @@ -40,6 +39,7 @@ export class Text extends UIRenderer implements ITextRenderer { private _text: string = ""; @ignoreClone private _localBounds: BoundingBox = new BoundingBox(); + @ignoreClone private _font: Font = null; private _fontSize: number = 24; private _fontStyle: FontStyle = FontStyle.None; diff --git a/packages/ui/src/component/interactive/UIInteractive.ts b/packages/ui/src/component/interactive/UIInteractive.ts index 88531e3944..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"; diff --git a/packages/ui/src/component/interactive/transition/SpriteTransition.ts b/packages/ui/src/component/interactive/transition/SpriteTransition.ts index 4a20c1bc13..8e349e734a 100644 --- a/packages/ui/src/component/interactive/transition/SpriteTransition.ts +++ b/packages/ui/src/component/interactive/transition/SpriteTransition.ts @@ -6,6 +6,21 @@ import { Transition } from "./Transition"; * Sprite transition. */ export class SpriteTransition extends Transition { + /** + * @internal + * The clone gate shares the state sprites by reference; acquire the counts that `destroy` releases. + */ + _cloneTo(target: SpriteTransition): void { + // @ts-ignore + target._normal?._addReferCount(1); + // @ts-ignore + target._hover?._addReferCount(1); + // @ts-ignore + target._pressed?._addReferCount(1); + // @ts-ignore + target._disabled?._addReferCount(1); + } + /** * @internal */ diff --git a/packages/ui/src/component/interactive/transition/Transition.ts b/packages/ui/src/component/interactive/transition/Transition.ts index 5dd461e615..ee889ba277 100644 --- a/packages/ui/src/component/interactive/transition/Transition.ts +++ b/packages/ui/src/component/interactive/transition/Transition.ts @@ -1,10 +1,11 @@ -import { Color, ReferResource, Sprite } from "@galacean/engine"; +import { CloneMode, Color, ReferResource, Sprite, defaultCloneMode } from "@galacean/engine"; import { UIRenderer } from "../../UIRenderer"; import { InteractiveState, UIInteractive } from "../UIInteractive"; /** * The transition behavior of UIInteractive. */ +@defaultCloneMode(CloneMode.Deep) export abstract class Transition< T extends TransitionValueType = TransitionValueType, K extends UIRenderer = UIRenderer diff --git a/tests/src/core/CloneTextureRefCount.test.ts b/tests/src/core/CloneTextureRefCount.test.ts index 830746bfa7..dfc9e29976 100644 --- a/tests/src/core/CloneTextureRefCount.test.ts +++ b/tests/src/core/CloneTextureRefCount.test.ts @@ -1,4 +1,14 @@ -import { MeshRenderer, Entity, Texture2D } from "@galacean/engine-core"; +import { + Animator, + AnimatorController, + Camera, + Entity, + Font, + MeshRenderer, + RenderTarget, + TextRenderer, + Texture2D +} from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; import { describe, beforeAll, expect, it } from "vitest"; @@ -29,4 +39,56 @@ describe("Clone resource refCount", async function () { 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 in Camera._cloneTo). + 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 via the font setter in _cloneTo). + 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 exactly one additional reference (acquired in Animator._cloneTo). + 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 index 400dbe4c3c..c62a5773eb 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -1,4 +1,13 @@ -import { Entity, MeshRenderer, Script, Signal, assignmentClone, deepClone, ignoreClone } from "@galacean/engine-core"; +import { + Entity, + MeshRenderer, + Script, + Signal, + Texture2D, + assignmentClone, + deepClone, + ignoreClone +} from "@galacean/engine-core"; import { WebGLEngine } from "@galacean/engine"; import { describe, expect, it } from "vitest"; @@ -45,14 +54,14 @@ class SiblingRefScript extends Script { /** Script with a mix of decorated and undecorated entity refs */ class DecoratedRefScript extends Script { - // Undecorated — should auto-remap via _remap + // Undecorated — Entity type default (Remap) applies autoRemapEntity: Entity; - // @assignmentClone — should still auto-remap since _remap takes priority + // @assignmentClone — field decorator wins over the type default: shares the source reference @assignmentClone assignedEntity: Entity; - // @ignoreClone — should still auto-remap since _remap takes priority + // @ignoreClone — field decorator wins over the type default: keeps the clone's own value @ignoreClone ignoredEntity: Entity; } @@ -112,6 +121,49 @@ class SignalScript extends Script { 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 }; +} + +/** Script whose constructor establishes its own bound handler */ +class BoundHandlerScript extends Script { + tickCount = 0; + boundTick = this._tick.bind(this); + + private _tick(): void { + this.tickCount++; + } +} + +/** Script holding binary data views */ +class BinaryScript extends Script { + view: DataView; + bytes: Float32Array; +} + +/** Script holding a shared ReferResource */ +class ResourceRefScript extends Script { + texture: Texture2D; +} + +/** Script misusing @deepClone on an Entity ref (must fall back to remap, never construct) */ +class DeepEntityRefScript extends Script { + @deepClone + target: Entity; +} + +/** Script with an @assignmentClone function field preset by the constructor */ +class AssignedHandlerScript extends Script { + @assignmentClone + 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; @@ -334,8 +386,8 @@ describe("Clone remap", async () => { }); }); - describe("Clone decorator interaction with _remap", () => { - it("@assignmentClone entity ref still gets remapped via _remap priority", () => { + 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"); @@ -345,13 +397,12 @@ describe("Clone remap", async () => { const cloned = parent.clone(); const cs = cloned.getComponent(DecoratedRefScript); - expect(cs.assignedEntity).not.eq(child); - expect(cs.assignedEntity).eq(cloned.children[0]); + expect(cs.assignedEntity).eq(child); rootEntity.destroy(); }); - it("@ignoreClone entity ref still gets remapped via _remap priority", () => { + 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"); @@ -361,8 +412,7 @@ describe("Clone remap", async () => { const cloned = parent.clone(); const cs = cloned.getComponent(DecoratedRefScript); - expect(cs.ignoredEntity).not.eq(child); - expect(cs.ignoredEntity).eq(cloned.children[0]); + expect(cs.ignoredEntity).eq(undefined); rootEntity.destroy(); }); @@ -383,7 +433,7 @@ describe("Clone remap", async () => { rootEntity.destroy(); }); - it("@ignoreClone entity ref outside hierarchy stays original", () => { + 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"); @@ -393,7 +443,43 @@ describe("Clone remap", async () => { const cloned = parent.clone(); const cs = cloned.getComponent(DecoratedRefScript); - expect(cs.ignoredEntity).eq(external); + expect(cs.ignoredEntity).eq(undefined); + + rootEntity.destroy(); + }); + + it("@deepClone entity ref falls back to remap instead of constructing a broken entity", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const child = parent.createChild("child"); + const external = rootEntity.createChild("external"); + const script = parent.addComponent(DeepEntityRefScript); + + // In-subtree ref remaps to the clone's entity. + script.target = child; + let cloned = parent.clone(); + expect(cloned.getComponent(DeepEntityRefScript).target).eq(cloned.children[0]); + + // Out-of-subtree ref keeps the original reference (never `new Entity()` without engine). + script.target = external; + cloned = parent.clone(); + expect(cloned.getComponent(DeepEntityRefScript).target).eq(external); + expect(cloned.getComponent(DeepEntityRefScript).target.engine).eq(engine); + + 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(); }); @@ -617,6 +703,26 @@ describe("Clone remap", async () => { 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"); @@ -712,6 +818,128 @@ describe("Clone remap", async () => { }); }); + describe("Function fields", () => { + 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("Binary data fields", () => { + 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(); + }); + }); + + describe("Script-held ReferResource", () => { + it("is shared by reference without touching refCount, and destroy stays balanced", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(ResourceRefScript); + const texture = new Texture2D(engine, 4, 4); + const baseline = texture.refCount; + script.texture = texture; + + const cloned = parent.clone(); + const cs = cloned.getComponent(ResourceRefScript); + + expect(cs.texture).eq(texture); + expect(texture.refCount).eq(baseline); + + cloned.destroy(); + expect(texture.refCount).eq(baseline); + + rootEntity.destroy(); + texture.destroy(); + }); + }); + describe("Single entity with multiple same-type components", () => { it("clone preserves multiple same-type components with correct state", () => { const rootEntity = scene.createRootEntity("root"); diff --git a/tests/src/core/Signal.test.ts b/tests/src/core/Signal.test.ts index cbf399a731..c67743e68b 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"; @@ -276,6 +276,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]>(); @@ -285,7 +296,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); @@ -306,7 +317,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(); @@ -329,7 +340,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); @@ -352,7 +363,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(); @@ -375,7 +386,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/physics/ColliderShape.test.ts b/tests/src/core/physics/ColliderShape.test.ts index d4c65c4c25..06d92538f8 100644 --- a/tests/src/core/physics/ColliderShape.test.ts +++ b/tests/src/core/physics/ColliderShape.test.ts @@ -582,4 +582,38 @@ describe("ColliderShape Lite", () => { expect(newCollider2.shapes.length).to.eq(1); expect((newCollider2.shapes[0] as BoxColliderShape).size).to.deep.include({ x: 1, y: 2, z: 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/ui/UIInteractive.test.ts b/tests/src/ui/UIInteractive.test.ts index 3889666fd8..482d76cfb5 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,66 @@ 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); + }); }); From 9c5469c4970d68817600895b9b7324aba27523f0 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 11:09:47 +0800 Subject: [PATCH 013/109] =?UTF-8?q?refactor(clone):=20slot-ownership=20ref?= =?UTF-8?q?=20counting=20=E2=80=94=20gate=20acquires,=20owner=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component top-level fields sharing a registered ref-counted resource (ReferResource family) acquire one reference at the clone gate (+1, and -1 when replacing an owned preset); the owning component's destroy path releases it (implementation contract). Scripts have no per-field destroy logic, so gate acquisitions are recorded in a ledger and released in Script._onDestroy. Below the top level the gate never counts: container elements and plain-object fields are plain shares, and nested classes pair the acquisition themselves (SpriteTransition._cloneTo +1 paired with Transition.destroy -1, hoisted to the base class so future ReferResource-valued transitions inherit the release). Manual +1 compensations in Camera/Animator/AudioSource._cloneTo are removed (the gate acquisition replaces them); TextRenderer/Text._font return to gate accounting. The counted-resource test is explicit registration (@defaultCloneMode(Assignment)), excluding duck-typed counters like Shader. Co-Authored-By: Claude Fable 5 --- packages/core/src/2d/text/TextRenderer.ts | 10 --- packages/core/src/Camera.ts | 7 -- packages/core/src/Script.ts | 18 ++++ packages/core/src/animation/Animator.ts | 8 +- packages/core/src/audio/AudioSource.ts | 8 -- packages/core/src/clone/CloneManager.ts | 83 +++++++++++++++---- packages/core/src/clone/ComponentCloner.ts | 12 ++- packages/ui/src/component/advanced/Text.ts | 9 -- .../transition/SpriteTransition.ts | 32 +------ .../interactive/transition/Transition.ts | 21 ++++- tests/src/core/CloneUtils.test.ts | 25 +++++- 11 files changed, 142 insertions(+), 91 deletions(-) diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index 65fce872fd..cdd4343715 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -46,7 +46,6 @@ export class TextRenderer extends Renderer implements ITextRenderer { private _height = 0; @ignoreClone private _localBounds = new BoundingBox(); - @ignoreClone private _font: Font = null; private _fontSize = 24; private _fontStyle = FontStyle.None; @@ -317,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 eb8aabb229..89579e376e 100644 --- a/packages/core/src/Camera.ts +++ b/packages/core/src/Camera.ts @@ -819,13 +819,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/Script.ts b/packages/core/src/Script.ts index 997901573d..d097a720e4 100644 --- a/packages/core/src/Script.ts +++ b/packages/core/src/Script.ts @@ -1,3 +1,4 @@ +import { IReferable } from "./asset/IReferable"; import { Camera } from "./Camera"; import { Component } from "./Component"; import { ignoreClone } from "./clone/CloneManager"; @@ -33,6 +34,13 @@ export class Script extends Component { /** @internal */ @ignoreClone _entityScriptsIndex: number = -1; + /** + * @internal + * Ref-counted resources acquired when this script was created by cloning (slot-ownership + * contract). Scripts have no per-field destroy logic, so the recorded refs are released here. + */ + @ignoreClone + _cloneAcquiredRefs: IReferable[] = null; /** * Called when be enabled first time, only once. @@ -259,10 +267,20 @@ export class Script extends Component { */ protected override _onDestroy(): void { super._onDestroy(); + const cloneAcquiredRefs = this._cloneAcquiredRefs; + if (cloneAcquiredRefs) { + for (let i = 0, n = cloneAcquiredRefs.length; i < n; i++) { + cloneAcquiredRefs[i]._addReferCount(-1); + } + this._cloneAcquiredRefs = null; + } this.onDestroy(); } } +// Scripts opt into the clone-acquisition ledger (non-enumerable so the clone field walk skips it). +Object.defineProperty(Script.prototype, "_useCloneRefLedger", { value: true }); + export enum PointerMethods { onPointerDown = "onPointerDown", onPointerUp = "onPointerUp", diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 03d1a93cd7..7a03e38cbb 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -315,9 +315,9 @@ export class Animator extends Component { */ _reset(): void { const { _curveOwnerPool: animationCurveOwners } = this; - for (let instanceId in animationCurveOwners) { + for (const instanceId in animationCurveOwners) { const propertyOwners = animationCurveOwners[instanceId]; - for (let property in propertyOwners) { + for (const property in propertyOwners) { const owner = propertyOwners[property]; owner.revertDefaultValue(); } @@ -342,9 +342,9 @@ export class Animator extends Component { * @internal */ _cloneTo(target: Animator): void { + // The clone gate already acquired the controller reference for the copied field slot. const animatorController = target._animatorController; if (animatorController) { - target._addResourceReferCount(animatorController, 1); target._controllerUpdateFlag = animatorController._registerChangeFlag(); } } @@ -430,7 +430,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/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index 80394a042e..c0f5efd980 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -215,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/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 10638281f3..b9a6eeda0d 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -13,6 +13,7 @@ import { Vector3, Vector4 } from "@galacean/engine-math"; +import { IReferable } from "../asset/IReferable"; import { TypedArray } from "../base/Constant"; import { Logger } from "../base/Logger"; import { ICustomClone } from "./ComponentCloner"; @@ -22,21 +23,21 @@ import { CloneMode } from "./enums/CloneMode"; * Property decorator — deep clone this field, overriding the value type's default clone mode. * Field-level decorators have the highest priority. */ -export function deepClone(target: Object, propertyKey: string): void { +export function deepClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); } /** * Property decorator — assign (share the reference) this field, overriding the value type's default clone mode. */ -export function assignmentClone(target: Object, propertyKey: string): void { +export function assignmentClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Assignment); } /** * Property decorator — ignore this field when cloning; keep the clone's own constructor-built value. */ -export function ignoreClone(target: Object, propertyKey: string): void { +export function ignoreClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore); } @@ -44,7 +45,7 @@ export function ignoreClone(target: Object, propertyKey: string): void { * @deprecated Shallow clone is no longer a distinct mode; treated as deep clone. * Use `@deepClone`, or `@assignmentClone` to share the reference. */ -export function shallowClone(target: Object, propertyKey: string): void { +export function shallowClone(target: object, propertyKey: string): void { Logger.warn( `@shallowClone is deprecated and now behaves as @deepClone ` + `(field "${propertyKey}" of ${target.constructor?.name}); use @deepClone or @assignmentClone instead.` @@ -84,10 +85,15 @@ export function defaultCloneMode(mode: CloneMode) { * - Assignment (ReferResource / unknown types without @defaultCloneMode) → share the reference. * - Deep (@defaultCloneMode(Deep) / copyFrom types / containers) → recursively deep clone. * - * The gate never touches reference counting: `Assignment` is a plain reference share. Ref-count - * ownership acquired by a clone belongs to the owner class's own logic (`_cloneTo` hooks and - * setters, balanced by that class's destroy path), e.g. `Camera._cloneTo`, `Renderer._cloneTo`, - * `ShaderData.cloneTo`. + * Ref-count (slot-ownership contract): every COMPONENT top-level field holding an explicitly + * registered ref-counted resource (ReferResource family) owns one reference. The gate acquires + * it when cloning the slot (+1, and -1 on a replaced preset); the owning component's destroy + * path MUST release it — a component that doesn't is a bug in that component. Components without + * per-field destroy logic (Script) record acquisitions and release them on destroy. Everything + * below the top level owns nothing at the gate: container elements and plain-object fields are + * plain shares, and a nested class that ref-counts its resources pairs the acquisition itself + * (`_cloneTo` +1 / destroy -1 in the same class). Slots rebuilt through setters must be + * `@ignoreClone` so the setter is the single +1 source (e.g. `MeshRenderer.mesh`). * * Deep clone lifecycle (3-stage): * 1. Construct — reuse the clone's existing slot value if same type, else `new ctor()`. @@ -98,9 +104,9 @@ export function defaultCloneMode(mode: CloneMode) { */ export class CloneManager { /** @internal Own field-level clone modes per class (excluding inherited), from `@deepClone`/`@assignmentClone`/`@ignoreClone`. */ - static _subFieldModeMap = new Map>(); + static _subFieldModeMap = new Map>(); /** @internal Flattened field-level clone modes per class (across the prototype chain), cached. */ - static _fieldModeMap = new Map>(); + static _fieldModeMap = new Map>(); private static _objectType = Object.getPrototypeOf(Object); @@ -108,7 +114,7 @@ export class CloneManager { * @internal * Register a field-level clone mode (highest priority — overrides the value type's `@defaultCloneMode`). */ - static _registerFieldMode(target: Object, propertyKey: string, mode: CloneMode): void { + static _registerFieldMode(target: object, propertyKey: string, mode: CloneMode): void { let fields = CloneManager._subFieldModeMap.get(target.constructor); if (!fields) { fields = new Map(); @@ -145,8 +151,21 @@ export class CloneManager { /** * @internal * Clone gate — decides how to clone one value based on its type. + * + * `refs` controls the ref-count contract for THIS slot: + * - `undefined` — the slot does not own a count (container elements, plain-object fields). + * - `null` — a class-instance field: an Assignment-shared ref-counted resource gains +1 here, + * and the owning class's destroy path releases it (implementation contract). + * - array — same as `null`, but the acquisition is also recorded (hosts with no per-field + * destroy logic, i.e. Script, release the recorded refs on destroy). */ - static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { + static _cloneValue( + value: any, + reuse: any, + cloneMap: Map, + fieldMode?: CloneMode, + refs?: IReferable[] | null + ): any { if (!(value instanceof Object)) return value; // Functions: an explicit field decorator (@assignmentClone/@deepClone) shares the reference; // by default keep the clone's own binding when its slot already holds one (constructor-rebound @@ -171,17 +190,44 @@ export class CloneManager { } if (cloneMode === CloneMode.Ignore) return reuse; - if (cloneMode === CloneMode.Assignment) return value; + if (cloneMode === CloneMode.Assignment) { + // Slot-ownership contract: a class-instance field sharing an explicitly-registered + // ref-counted resource (ReferResource family — excludes duck-typed counters like Shader) + // owns one reference; the owning class's destroy path (or the recorded ledger) releases it. + if (refs !== undefined && CloneManager._isCountedResource(value)) { + if (CloneManager._isCountedResource(reuse)) { + const presetRefCount = (<{ refCount?: number }>reuse).refCount; + presetRefCount !== undefined && + presetRefCount <= 0 && + Logger.error( + `CloneManager: the clone's preset ${reuse.constructor.name} holds no owned reference; ` + + `a constructor presetting a ref-counted resource must acquire it (assign via its setter or an explicit +1).` + ); + (reuse)._addReferCount(-1); + } + (value)._addReferCount(1); + refs?.push(value); + } + return value; + } if (cloneMode === CloneMode.Remap) return cloneMap.get(value) ?? value; return CloneManager._deepClone(value, reuse, cloneMap); } + /** + * Whether the value participates in the slot-ownership ref-count contract: only types + * explicitly registered `@defaultCloneMode(Assignment)` (the ReferResource family) do. + */ + private static _isCountedResource(value: any): boolean { + return value instanceof Object && (value)._defaultCloneMode === CloneMode.Assignment; + } + /** * Container-shape test — the single classification point shared by the gate and `_deepClone`. * Invariant: every shape this returns true for MUST have a dedicated branch in `_deepClone` * (ArrayBuffer view → byte copy, Array/Map/Set → per-element, plain object → field walk). */ - private static _isContainer(value: Object): boolean { + private static _isContainer(value: object): boolean { return ( Array.isArray(value) || value instanceof Map || @@ -195,7 +241,7 @@ export class CloneManager { * Deep-clone one object graph. Cycles / shared sub-graphs dedup through the identity map, * which also remaps Entity/Component references nested anywhere in the graph. */ - private static _deepClone(value: any, reuse: any, cloneMap: Map): any { + private static _deepClone(value: any, reuse: any, cloneMap: Map): any { const existing = cloneMap.get(value); if (existing) return existing; @@ -261,6 +307,9 @@ export class CloneManager { const dst = reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); cloneMap.set(value, dst); + // Nested objects own no gate-acquired references: the slot-ownership contract covers + // component top-level fields only. A nested class that ref-counts its resources pairs the + // acquisition itself (`_cloneTo` +1 / destroy -1 in the same class, e.g. SpriteTransition). const fieldModes = CloneManager.getFieldModes(value.constructor); for (const key in value) { const fieldMode = fieldModes.get(key); @@ -274,8 +323,8 @@ export class CloneManager { /** * Deep clone all enumerable fields of source into target through the clone gate. */ - static deepCloneObject(source: Object, target: Object, cloneMap: Map): void { - for (let k in source) { + static deepCloneObject(source: object, target: object, cloneMap: Map): void { + for (const k in source) { target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap); } } diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 81e9a23b9e..d1b9122dc0 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -1,3 +1,4 @@ +import { IReferable } from "../asset/IReferable"; import { Component } from "../Component"; import { CloneManager } from "./CloneManager"; import { CloneMode } from "./enums/CloneMode"; @@ -17,7 +18,7 @@ export interface ICustomClone { * Post-clone hook. `cloneMap` maps every source entity/component (and deep-cloned object) * in the cloned subtree to its clone, for remapping references. */ - _cloneTo?(target: ICustomClone, cloneMap?: Map): void; + _cloneTo?(target: ICustomClone, cloneMap?: Map): void; /** * @internal */ @@ -31,13 +32,18 @@ export class ComponentCloner { * @param target - Clone target * @param cloneMap - Identity map of the cloned subtree (source entity/component → clone) */ - static cloneComponent(source: Component, target: Component, cloneMap: Map): void { + static cloneComponent(source: Component, target: Component, cloneMap: Map): void { + // Component fields own their shared ref-counted resources (slot-ownership contract). Hosts + // without per-field destroy logic (Script) record the acquisitions and release them on destroy. + const refs: IReferable[] | null = (target)._useCloneRefLedger + ? ((target)._cloneAcquiredRefs ||= []) + : null; const fieldModes = CloneManager.getFieldModes(source.constructor); for (const k in source) { const fieldMode = fieldModes.get(k); if (fieldMode === CloneMode.Ignore) continue; // Field decorator (highest) → value-shape / type default (Entity/Component remap via the map). - target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode); + target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode, refs); } ((source as unknown))._cloneTo?.(target, cloneMap); } diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index 5c87bc1c0a..dc07ea5ccb 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -39,7 +39,6 @@ export class Text extends UIRenderer implements ITextRenderer { private _text: string = ""; @ignoreClone private _localBounds: BoundingBox = new BoundingBox(); - @ignoreClone private _font: Font = null; private _fontSize: number = 24; private _fontStyle: FontStyle = FontStyle.None; @@ -255,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/transition/SpriteTransition.ts b/packages/ui/src/component/interactive/transition/SpriteTransition.ts index 8e349e734a..9e4c344e2b 100644 --- a/packages/ui/src/component/interactive/transition/SpriteTransition.ts +++ b/packages/ui/src/component/interactive/transition/SpriteTransition.ts @@ -8,7 +8,8 @@ import { Transition } from "./Transition"; export class SpriteTransition extends Transition { /** * @internal - * The clone gate shares the state sprites by reference; acquire the counts that `destroy` releases. + * The clone gate shares nested state sprites without counting; pair the acquisition here + * with the release in `Transition.destroy` (class-local ownership). */ _cloneTo(target: SpriteTransition): void { // @ts-ignore @@ -21,35 +22,6 @@ export class SpriteTransition extends Transition { target._disabled?._addReferCount(1); } - /** - * @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 ee889ba277..0dcdacd83c 100644 --- a/packages/ui/src/component/interactive/transition/Transition.ts +++ b/packages/ui/src/component/interactive/transition/Transition.ts @@ -1,4 +1,4 @@ -import { CloneMode, Color, ReferResource, Sprite, defaultCloneMode } from "@galacean/engine"; +import { CloneMode, Color, ReferResource, Sprite, defaultCloneMode, ignoreClone } from "@galacean/engine"; import { UIRenderer } from "../../UIRenderer"; import { InteractiveState, UIInteractive } from "../UIInteractive"; @@ -19,10 +19,17 @@ export abstract class Transition< protected _hover: T; protected _disabled: T; protected _duration: number = 0; + // Transient run state — rebuilt by `_setState` when the cloned interactive activates; the + // slots own no resource reference (destroy only releases the four state values). + @ignoreClone protected _countDown: number = 0; + @ignoreClone protected _initialValue: T; + @ignoreClone protected _finalValue: T; + @ignoreClone protected _currentValue: T; + @ignoreClone protected _finalState: InteractiveState = InteractiveState.Normal; /** @@ -120,6 +127,18 @@ export abstract class Transition< destroy(): void { this._interactive?.removeTransition(this); + // Release the ref-counted state values (paired with the setter's +1 and, for clones, + // `_cloneTo`'s +1) — implemented here so every subclass with ReferResource states is covered. + const releaseState = (state: T): void => { + // @ts-ignore + state instanceof ReferResource && state._addReferCount(-1); + }; + releaseState(this._normal); + releaseState(this._pressed); + releaseState(this._hover); + releaseState(this._disabled); + this._normal = this._pressed = this._hover = this._disabled = null; + this._initialValue = this._currentValue = this._finalValue = null; this._target = null; } diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index 039f985ca5..85777fee2b 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -918,7 +918,7 @@ describe("Clone remap", async () => { }); describe("Script-held ReferResource", () => { - it("is shared by reference without touching refCount, and destroy stays balanced", () => { + it("clone acquires one reference (ledger) and destroy releases it", () => { const rootEntity = scene.createRootEntity("root"); const parent = rootEntity.createChild("parent"); const script = parent.addComponent(ResourceRefScript); @@ -929,8 +929,9 @@ describe("Clone remap", async () => { const cloned = parent.clone(); const cs = cloned.getComponent(ResourceRefScript); + // Shared by reference; the cloned slot owns exactly one count, recorded in the ledger. expect(cs.texture).eq(texture); - expect(texture.refCount).eq(baseline); + expect(texture.refCount).eq(baseline + 1); cloned.destroy(); expect(texture.refCount).eq(baseline); @@ -938,6 +939,26 @@ describe("Clone remap", async () => { rootEntity.destroy(); texture.destroy(); }); + + it("re-cloning a clone stays balanced (ledger per clone)", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(ResourceRefScript); + const texture = new Texture2D(engine, 4, 4); + const baseline = texture.refCount; + script.texture = texture; + + const cloneA = parent.clone(); + const cloneB = cloneA.clone(); + expect(texture.refCount).eq(baseline + 2); + + cloneA.destroy(); + cloneB.destroy(); + expect(texture.refCount).eq(baseline); + + rootEntity.destroy(); + texture.destroy(); + }); }); describe("Single entity with multiple same-type components", () => { From c2790dd49d4879f81d719c9768469d176dc77f6a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 14:21:08 +0800 Subject: [PATCH 014/109] test(clone): refCount lifecycle suites + fix particle MeshShape mesh leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add slot-ownership contract suites asserting the full lifecycle (baseline → clone +1 → setter churn → destroy release) for every counted component slot: Camera.renderTarget, Animator.controller, AudioSource.clip, SpriteRenderer/SpriteMask/ui Image sprite, MeshRenderer.mesh, MeshColliderShape.mesh, SpriteTransition states, and the Script ledger (reassignment still releases the original). Add ShaderData/Material cascade suites: setTexture swap/clear, doubly-referenced hosts (±refCount propagation), renderer clone balance, and instance-material lifecycles. The new suites exposed a pre-existing leak: destroying a ParticleRenderer never released the MeshShape's mesh (+1 in the mesh setter, no release path). Add BaseShape._destroy, called from EmissionModule._destroy, with MeshShape releasing through its setter. Co-Authored-By: Claude Fable 5 --- .../src/particle/modules/EmissionModule.ts | 6 +- .../src/particle/modules/shape/BaseShape.ts | 7 + .../src/particle/modules/shape/MeshShape.ts | 8 + tests/src/core/CloneUtils.test.ts | 27 +++ tests/src/core/RefCountContract.test.ts | 224 ++++++++++++++++++ tests/src/core/ShaderDataRefCount.test.ts | 113 +++++++++ .../core/physics/MeshColliderShape.test.ts | 30 +++ tests/src/ui/Image.test.ts | 23 ++ tests/src/ui/UIInteractive.test.ts | 32 +++ 9 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 tests/src/core/RefCountContract.test.ts create mode 100644 tests/src/core/ShaderDataRefCount.test.ts diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts index 88d1baf038..9e0cad5086 100644 --- a/packages/core/src/particle/modules/EmissionModule.ts +++ b/packages/core/src/particle/modules/EmissionModule.ts @@ -168,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/shape/BaseShape.ts b/packages/core/src/particle/modules/shape/BaseShape.ts index c1edb64417..4ba7f16e30 100644 --- a/packages/core/src/particle/modules/shape/BaseShape.ts +++ b/packages/core/src/particle/modules/shape/BaseShape.ts @@ -127,6 +127,13 @@ export abstract class BaseShape { this._updateManager.removeListener(listener); } + /** + * @internal + * Called when the hosting generator is destroyed; shapes holding ref-counted resources + * release them here (slot-ownership contract). + */ + _destroy(): void {} + /** * @internal */ diff --git a/packages/core/src/particle/modules/shape/MeshShape.ts b/packages/core/src/particle/modules/shape/MeshShape.ts index e425003704..f0cd4f08f3 100644 --- a/packages/core/src/particle/modules/shape/MeshShape.ts +++ b/packages/core/src/particle/modules/shape/MeshShape.ts @@ -51,6 +51,14 @@ export class MeshShape extends BaseShape { } } + /** + * @internal + * Release the mesh reference through the setter (pairs its +1; also detaches the listener). + */ + override _destroy(): void { + this.mesh = null; + } + /** * @internal */ diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index 85777fee2b..63d802a55c 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -940,6 +940,33 @@ describe("Clone remap", async () => { texture.destroy(); }); + it("ledger releases the originally acquired resource even after the field is reassigned", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(ResourceRefScript); + const texture = new Texture2D(engine, 4, 4); + const other = new Texture2D(engine, 4, 4); + const baseline = texture.refCount; + script.texture = texture; + + const cloned = parent.clone(); + const cs = cloned.getComponent(ResourceRefScript); + expect(texture.refCount).eq(baseline + 1); + + // Plain field write on a script does no accounting; the ledger still tracks the original. + cs.texture = other; + expect(texture.refCount).eq(baseline + 1); + expect(other.refCount).eq(0); + + cloned.destroy(); + expect(texture.refCount).eq(baseline); + expect(other.refCount).eq(0); + + rootEntity.destroy(); + texture.destroy(); + other.destroy(); + }); + it("re-cloning a clone stays balanced (ledger per clone)", () => { const rootEntity = scene.createRootEntity("root"); const parent = rootEntity.createChild("parent"); diff --git a/tests/src/core/RefCountContract.test.ts b/tests/src/core/RefCountContract.test.ts new file mode 100644 index 0000000000..bb5fc68066 --- /dev/null +++ b/tests/src/core/RefCountContract.test.ts @@ -0,0 +1,224 @@ +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); + }); + }); +}); diff --git a/tests/src/core/ShaderDataRefCount.test.ts b/tests/src/core/ShaderDataRefCount.test.ts new file mode 100644 index 0000000000..a61f0c73fd --- /dev/null +++ b/tests/src/core/ShaderDataRefCount.test.ts @@ -0,0 +1,113 @@ +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); + }); +}); diff --git a/tests/src/core/physics/MeshColliderShape.test.ts b/tests/src/core/physics/MeshColliderShape.test.ts index 9506569526..48b06b3990 100644 --- a/tests/src/core/physics/MeshColliderShape.test.ts +++ b/tests/src/core/physics/MeshColliderShape.test.ts @@ -764,4 +764,34 @@ describe("MeshColliderShape PhysX", () => { entity.destroy(); }); }); + + describe("mesh refCount (slot-ownership contract)", () => { + it("clone acquires via the setter, churn transfers, destroy releases", () => { + const meshA = createModelMesh(engine, [-1, 0, -1, 1, 0, -1, 0, 0, 1], [0, 1, 2]); + const meshB = createModelMesh(engine, [-2, 0, -2, 2, 0, -2, 0, 0, 2], [0, 1, 2]); + const entity = root.createChild("meshRefSlot"); + const collider = entity.addComponent(StaticCollider); + const shape = new MeshColliderShape(); + shape.mesh = meshA; + collider.addShape(shape); + expect(meshA.refCount).toBe(1); + + const clone = entity.clone(); + root.addChild(clone); + expect(meshA.refCount).toBe(2); + + const clonedShape = clone.getComponent(StaticCollider).shapes[0] as MeshColliderShape; + expect(clonedShape).not.toBe(shape); + clonedShape.mesh = meshB; + expect(meshA.refCount).toBe(1); + expect(meshB.refCount).toBe(1); + + clone.destroy(); + expect(meshB.refCount).toBe(0); + expect(meshA.refCount).toBe(1); + + entity.destroy(); + expect(meshA.refCount).toBe(0); + }); + }); }); diff --git a/tests/src/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 e6aa3e163c..4bba12b37b 100644 --- a/tests/src/ui/UIInteractive.test.ts +++ b/tests/src/ui/UIInteractive.test.ts @@ -314,4 +314,36 @@ describe("Button", async () => { 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); + }); }); From 60135e99d5db2e26c57d9e1f580e5dc122798b56 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 14:23:26 +0800 Subject: [PATCH 015/109] refactor(clone)!: remove the deprecated shallowClone decorator Shallow is no longer a distinct mode; keeping the decorator silently behaving as deep clone hides the semantic change. Migrate with @deepClone (independent copy) or @assignmentClone (share the reference). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index b9a6eeda0d..630a5763bc 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -41,18 +41,6 @@ export function ignoreClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore); } -/** - * @deprecated Shallow clone is no longer a distinct mode; treated as deep clone. - * Use `@deepClone`, or `@assignmentClone` to share the reference. - */ -export function shallowClone(target: object, propertyKey: string): void { - Logger.warn( - `@shallowClone is deprecated and now behaves as @deepClone ` + - `(field "${propertyKey}" of ${target.constructor?.name}); use @deepClone or @assignmentClone instead.` - ); - CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); -} - /** * Class decorator that sets the default clone mode for instances of the decorated type. * From 2a4309e7a2bc5b6447184cec9ac8eda3389c5613 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 14:29:22 +0800 Subject: [PATCH 016/109] style(clone): order CloneManager members by visibility Public API (getFieldModes, deepCloneObject) first, @internal entries (_registerFieldMode, _cloneValue) after, private helpers last. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 46 ++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 630a5763bc..239afee622 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -98,20 +98,6 @@ export class CloneManager { private static _objectType = Object.getPrototypeOf(Object); - /** - * @internal - * Register a field-level clone mode (highest priority — overrides the value type's `@defaultCloneMode`). - */ - static _registerFieldMode(target: object, propertyKey: string, mode: CloneMode): void { - let fields = CloneManager._subFieldModeMap.get(target.constructor); - if (!fields) { - fields = new Map(); - CloneManager._subFieldModeMap.set(target.constructor, fields); - } - fields.set(propertyKey, mode); - CloneManager._fieldModeMap.clear(); - } - /** * Get the field-level clone modes of a type, flattened across its prototype chain. */ @@ -136,6 +122,29 @@ export class CloneManager { return modes; } + /** + * Deep clone all enumerable fields of source into target through the clone gate. + */ + static deepCloneObject(source: object, target: object, cloneMap: Map): void { + for (const k in source) { + target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap); + } + } + + /** + * @internal + * Register a field-level clone mode (highest priority — overrides the value type's `@defaultCloneMode`). + */ + static _registerFieldMode(target: object, propertyKey: string, mode: CloneMode): void { + let fields = CloneManager._subFieldModeMap.get(target.constructor); + if (!fields) { + fields = new Map(); + CloneManager._subFieldModeMap.set(target.constructor, fields); + } + fields.set(propertyKey, mode); + CloneManager._fieldModeMap.clear(); + } + /** * @internal * Clone gate — decides how to clone one value based on its type. @@ -307,15 +316,6 @@ export class CloneManager { (value)._cloneTo?.(dst, cloneMap); return dst; } - - /** - * Deep clone all enumerable fields of source into target through the clone gate. - */ - static deepCloneObject(source: object, target: object, cloneMap: Map): void { - for (const k in source) { - target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap); - } - } } // Built-in default clone mode for math value types. The math package cannot depend on core's From 88625db4b3be672d2355786517f0e0986530acb6 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 14:36:36 +0800 Subject: [PATCH 017/109] refactor(clone): exempt user scripts from gate ref counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Script fields are user territory: the engine cannot pair a release for an automatic acquisition (users can't know which slots were acquired), so the gate does no counting for script slots at all — replacing the clone-acquisition ledger. Users who need gc protection manage counts themselves, matching the pre-2.0 semantics. Co-Authored-By: Claude Fable 5 --- packages/core/src/Script.ts | 21 ++------- packages/core/src/animation/Animator.ts | 1 - packages/core/src/clone/CloneManager.ts | 41 +++++++---------- packages/core/src/clone/ComponentCloner.ts | 10 ++-- packages/core/src/clone/enums/CloneMode.ts | 8 ++-- tests/src/core/CloneUtils.test.ts | 53 ++-------------------- 6 files changed, 34 insertions(+), 100 deletions(-) diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts index d097a720e4..a183b5a85b 100644 --- a/packages/core/src/Script.ts +++ b/packages/core/src/Script.ts @@ -1,4 +1,3 @@ -import { IReferable } from "./asset/IReferable"; import { Camera } from "./Camera"; import { Component } from "./Component"; import { ignoreClone } from "./clone/CloneManager"; @@ -34,13 +33,6 @@ export class Script extends Component { /** @internal */ @ignoreClone _entityScriptsIndex: number = -1; - /** - * @internal - * Ref-counted resources acquired when this script was created by cloning (slot-ownership - * contract). Scripts have no per-field destroy logic, so the recorded refs are released here. - */ - @ignoreClone - _cloneAcquiredRefs: IReferable[] = null; /** * Called when be enabled first time, only once. @@ -267,19 +259,14 @@ export class Script extends Component { */ protected override _onDestroy(): void { super._onDestroy(); - const cloneAcquiredRefs = this._cloneAcquiredRefs; - if (cloneAcquiredRefs) { - for (let i = 0, n = cloneAcquiredRefs.length; i < n; i++) { - cloneAcquiredRefs[i]._addReferCount(-1); - } - this._cloneAcquiredRefs = null; - } this.onDestroy(); } } -// Scripts opt into the clone-acquisition ledger (non-enumerable so the clone field walk skips it). -Object.defineProperty(Script.prototype, "_useCloneRefLedger", { value: true }); +// User-script fields are user-managed: the clone gate does no ref counting for them (the engine +// cannot pair a release, and users can't know which slots were acquired). Users who want gc +// protection manage counts themselves. Non-enumerable so the clone field walk skips it. +Object.defineProperty(Script.prototype, "_skipCloneRefCounting", { value: true }); export enum PointerMethods { onPointerDown = "onPointerDown", diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 7a03e38cbb..1e837bde91 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -342,7 +342,6 @@ export class Animator extends Component { * @internal */ _cloneTo(target: Animator): void { - // The clone gate already acquired the controller reference for the copied field slot. const animatorController = target._animatorController; if (animatorController) { target._controllerUpdateFlag = animatorController._registerChangeFlag(); diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 239afee622..9a3a3a32f0 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -73,15 +73,16 @@ export function defaultCloneMode(mode: CloneMode) { * - Assignment (ReferResource / unknown types without @defaultCloneMode) → share the reference. * - Deep (@defaultCloneMode(Deep) / copyFrom types / containers) → recursively deep clone. * - * Ref-count (slot-ownership contract): every COMPONENT top-level field holding an explicitly - * registered ref-counted resource (ReferResource family) owns one reference. The gate acquires - * it when cloning the slot (+1, and -1 on a replaced preset); the owning component's destroy - * path MUST release it — a component that doesn't is a bug in that component. Components without - * per-field destroy logic (Script) record acquisitions and release them on destroy. Everything - * below the top level owns nothing at the gate: container elements and plain-object fields are - * plain shares, and a nested class that ref-counts its resources pairs the acquisition itself - * (`_cloneTo` +1 / destroy -1 in the same class). Slots rebuilt through setters must be - * `@ignoreClone` so the setter is the single +1 source (e.g. `MeshRenderer.mesh`). + * Ref-count (slot-ownership contract): every ENGINE-COMPONENT top-level field holding an + * explicitly registered ref-counted resource (ReferResource family) owns one reference. The gate + * acquires it when cloning the slot (+1, and -1 on a replaced preset); the owning component's + * destroy path MUST release it — a component that doesn't is a bug in that component. User + * scripts are exempt (`Script.prototype._skipCloneRefCounting`): their fields are user-managed + * and the gate does no counting for them. Everything below the top level owns nothing at the + * gate: container elements and plain-object fields are plain shares, and a nested class that + * ref-counts its resources pairs the acquisition itself (`_cloneTo` +1 / destroy -1 in the same + * class). Slots rebuilt through setters must be `@ignoreClone` so the setter is the single +1 + * source (e.g. `MeshRenderer.mesh`). * * Deep clone lifecycle (3-stage): * 1. Construct — reuse the clone's existing slot value if same type, else `new ctor()`. @@ -150,19 +151,12 @@ export class CloneManager { * Clone gate — decides how to clone one value based on its type. * * `refs` controls the ref-count contract for THIS slot: - * - `undefined` — the slot does not own a count (container elements, plain-object fields). - * - `null` — a class-instance field: an Assignment-shared ref-counted resource gains +1 here, - * and the owning class's destroy path releases it (implementation contract). - * - array — same as `null`, but the acquisition is also recorded (hosts with no per-field - * destroy logic, i.e. Script, release the recorded refs on destroy). + * - `undefined` — the slot owns no count (container elements, plain-object fields, + * user-script fields). + * - `null` — an engine-component field: an Assignment-shared ref-counted resource gains +1 + * here, and the owning component's destroy path releases it (implementation contract). */ - static _cloneValue( - value: any, - reuse: any, - cloneMap: Map, - fieldMode?: CloneMode, - refs?: IReferable[] | null - ): any { + static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode, refs?: null): any { if (!(value instanceof Object)) return value; // Functions: an explicit field decorator (@assignmentClone/@deepClone) shares the reference; // by default keep the clone's own binding when its slot already holds one (constructor-rebound @@ -188,9 +182,9 @@ export class CloneManager { if (cloneMode === CloneMode.Ignore) return reuse; if (cloneMode === CloneMode.Assignment) { - // Slot-ownership contract: a class-instance field sharing an explicitly-registered + // Slot-ownership contract: an engine-component field sharing an explicitly-registered // ref-counted resource (ReferResource family — excludes duck-typed counters like Shader) - // owns one reference; the owning class's destroy path (or the recorded ledger) releases it. + // owns one reference; the owning component's destroy path releases it. if (refs !== undefined && CloneManager._isCountedResource(value)) { if (CloneManager._isCountedResource(reuse)) { const presetRefCount = (<{ refCount?: number }>reuse).refCount; @@ -203,7 +197,6 @@ export class CloneManager { (reuse)._addReferCount(-1); } (value)._addReferCount(1); - refs?.push(value); } return value; } diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index d1b9122dc0..3b25439d87 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -1,4 +1,3 @@ -import { IReferable } from "../asset/IReferable"; import { Component } from "../Component"; import { CloneManager } from "./CloneManager"; import { CloneMode } from "./enums/CloneMode"; @@ -33,11 +32,10 @@ export class ComponentCloner { * @param cloneMap - Identity map of the cloned subtree (source entity/component → clone) */ static cloneComponent(source: Component, target: Component, cloneMap: Map): void { - // Component fields own their shared ref-counted resources (slot-ownership contract). Hosts - // without per-field destroy logic (Script) record the acquisitions and release them on destroy. - const refs: IReferable[] | null = (target)._useCloneRefLedger - ? ((target)._cloneAcquiredRefs ||= []) - : null; + // Engine-component fields own their shared ref-counted resources (slot-ownership contract: + // gate +1, the component's destroy path releases). User scripts are exempt — their fields are + // user-managed, so the gate does no counting for them. + const refs: null | undefined = (target)._skipCloneRefCounting ? undefined : null; const fieldModes = CloneManager.getFieldModes(source.constructor); for (const k in source) { const fieldMode = fieldModes.get(k); diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index cd83b51de3..0c12b6b103 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -2,12 +2,12 @@ * How a value is cloned, decided by its type (see `@defaultCloneMode`). */ export enum CloneMode { + /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */ + Ignore, /** Share the reference; a ref-counted resource is kept alive by the clone. */ Assignment, - /** Recursively deep clone the value, producing an independent copy. */ - Deep, /** Remap an Entity / Component reference to its clone within the cloned subtree. */ Remap, - /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */ - Ignore + /** Recursively deep clone the value, producing an independent copy. */ + Deep } diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index 63d802a55c..bfd4af1d6f 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -918,7 +918,7 @@ describe("Clone remap", async () => { }); describe("Script-held ReferResource", () => { - it("clone acquires one reference (ledger) and destroy releases it", () => { + it("is shared with no engine accounting — script fields are user-managed", () => { const rootEntity = scene.createRootEntity("root"); const parent = rootEntity.createChild("parent"); const script = parent.addComponent(ResourceRefScript); @@ -929,58 +929,15 @@ describe("Clone remap", async () => { const cloned = parent.clone(); const cs = cloned.getComponent(ResourceRefScript); - // Shared by reference; the cloned slot owns exactly one count, recorded in the ledger. + // Shared by reference; the gate does no counting for user-script slots. expect(cs.texture).eq(texture); - expect(texture.refCount).eq(baseline + 1); - - cloned.destroy(); expect(texture.refCount).eq(baseline); - rootEntity.destroy(); - texture.destroy(); - }); - - it("ledger releases the originally acquired resource even after the field is reassigned", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(ResourceRefScript); - const texture = new Texture2D(engine, 4, 4); - const other = new Texture2D(engine, 4, 4); - const baseline = texture.refCount; - script.texture = texture; - - const cloned = parent.clone(); - const cs = cloned.getComponent(ResourceRefScript); - expect(texture.refCount).eq(baseline + 1); - - // Plain field write on a script does no accounting; the ledger still tracks the original. - cs.texture = other; - expect(texture.refCount).eq(baseline + 1); - expect(other.refCount).eq(0); - - cloned.destroy(); + const recloned = cloned.clone(); expect(texture.refCount).eq(baseline); - expect(other.refCount).eq(0); - - rootEntity.destroy(); - texture.destroy(); - other.destroy(); - }); - it("re-cloning a clone stays balanced (ledger per clone)", () => { - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(ResourceRefScript); - const texture = new Texture2D(engine, 4, 4); - const baseline = texture.refCount; - script.texture = texture; - - const cloneA = parent.clone(); - const cloneB = cloneA.clone(); - expect(texture.refCount).eq(baseline + 2); - - cloneA.destroy(); - cloneB.destroy(); + cloned.destroy(); + recloned.destroy(); expect(texture.refCount).eq(baseline); rootEntity.destroy(); From 2239fbd37a54879bb4dbbbb180403b70b8a0a126 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 14:41:39 +0800 Subject: [PATCH 018/109] =?UTF-8?q?refactor(clone):=20one=20uniform=20slot?= =?UTF-8?q?=20contract=20=E2=80=94=20no=20script=20special-casing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloning's only ref-count job is acquiring the count for the slot it copies. Releasing on destroy is the owning class's existing contract: engine components in their destroy paths, script authors in onDestroy. Remove the script exemption flag; Script.ts is untouched by this PR. Co-Authored-By: Claude Fable 5 --- packages/core/src/Script.ts | 5 --- packages/core/src/clone/CloneManager.ts | 26 +++++++------- packages/core/src/clone/ComponentCloner.ts | 7 ++-- tests/src/core/CloneUtils.test.ts | 42 ++++++++++++++++------ 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts index a183b5a85b..997901573d 100644 --- a/packages/core/src/Script.ts +++ b/packages/core/src/Script.ts @@ -263,11 +263,6 @@ export class Script extends Component { } } -// User-script fields are user-managed: the clone gate does no ref counting for them (the engine -// cannot pair a release, and users can't know which slots were acquired). Users who want gc -// protection manage counts themselves. Non-enumerable so the clone field walk skips it. -Object.defineProperty(Script.prototype, "_skipCloneRefCounting", { value: true }); - export enum PointerMethods { onPointerDown = "onPointerDown", onPointerUp = "onPointerUp", diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 9a3a3a32f0..40cf323e33 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -73,16 +73,15 @@ export function defaultCloneMode(mode: CloneMode) { * - Assignment (ReferResource / unknown types without @defaultCloneMode) → share the reference. * - Deep (@defaultCloneMode(Deep) / copyFrom types / containers) → recursively deep clone. * - * Ref-count (slot-ownership contract): every ENGINE-COMPONENT top-level field holding an - * explicitly registered ref-counted resource (ReferResource family) owns one reference. The gate - * acquires it when cloning the slot (+1, and -1 on a replaced preset); the owning component's - * destroy path MUST release it — a component that doesn't is a bug in that component. User - * scripts are exempt (`Script.prototype._skipCloneRefCounting`): their fields are user-managed - * and the gate does no counting for them. Everything below the top level owns nothing at the - * gate: container elements and plain-object fields are plain shares, and a nested class that - * ref-counts its resources pairs the acquisition itself (`_cloneTo` +1 / destroy -1 in the same - * class). Slots rebuilt through setters must be `@ignoreClone` so the setter is the single +1 - * source (e.g. `MeshRenderer.mesh`). + * Ref-count (slot-ownership contract): every COMPONENT top-level field holding an explicitly + * registered ref-counted resource (ReferResource family) owns one reference. The gate acquires + * it when cloning the slot (+1, and -1 on a replaced preset); releasing it on destroy is the + * component class's responsibility — engine components do it in their destroy paths, script + * authors in `onDestroy`. A class that doesn't release is a bug in that class. Everything below + * the top level owns nothing at the gate: container elements and plain-object fields are plain + * shares, and a nested class that ref-counts its resources pairs the acquisition itself + * (`_cloneTo` +1 / destroy -1 in the same class). Slots rebuilt through setters must be + * `@ignoreClone` so the setter is the single +1 source (e.g. `MeshRenderer.mesh`). * * Deep clone lifecycle (3-stage): * 1. Construct — reuse the clone's existing slot value if same type, else `new ctor()`. @@ -151,10 +150,9 @@ export class CloneManager { * Clone gate — decides how to clone one value based on its type. * * `refs` controls the ref-count contract for THIS slot: - * - `undefined` — the slot owns no count (container elements, plain-object fields, - * user-script fields). - * - `null` — an engine-component field: an Assignment-shared ref-counted resource gains +1 - * here, and the owning component's destroy path releases it (implementation contract). + * - `undefined` — the slot owns no count (container elements, plain-object fields). + * - `null` — a component top-level field: an Assignment-shared ref-counted resource gains +1 + * here, and the owning class's destroy path releases it (implementation contract). */ static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode, refs?: null): any { if (!(value instanceof Object)) return value; diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 3b25439d87..283ed613f9 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -32,16 +32,13 @@ export class ComponentCloner { * @param cloneMap - Identity map of the cloned subtree (source entity/component → clone) */ static cloneComponent(source: Component, target: Component, cloneMap: Map): void { - // Engine-component fields own their shared ref-counted resources (slot-ownership contract: - // gate +1, the component's destroy path releases). User scripts are exempt — their fields are - // user-managed, so the gate does no counting for them. - const refs: null | undefined = (target)._skipCloneRefCounting ? undefined : null; const fieldModes = CloneManager.getFieldModes(source.constructor); for (const k in source) { const fieldMode = fieldModes.get(k); if (fieldMode === CloneMode.Ignore) continue; // Field decorator (highest) → value-shape / type default (Entity/Component remap via the map). - target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode, refs); + // Component top-level slots own their shared ref-counted resources (`refs: null`). + target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode, null); } ((source as unknown))._cloneTo?.(target, cloneMap); } diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index bfd4af1d6f..bb05863ca1 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -145,9 +145,29 @@ class BinaryScript extends Script { bytes: Float32Array; } -/** Script holding a shared ReferResource */ +/** + * 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 { - texture: Texture2D; + 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 misusing @deepClone on an Entity ref (must fall back to remap, never construct) */ @@ -918,27 +938,27 @@ describe("Clone remap", async () => { }); describe("Script-held ReferResource", () => { - it("is shared with no engine accounting — script fields are user-managed", () => { + 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); - const baseline = texture.refCount; script.texture = texture; + expect(texture.refCount).eq(1); const cloned = parent.clone(); const cs = cloned.getComponent(ResourceRefScript); - // Shared by reference; the gate does no counting for user-script slots. + // Shared by reference; the cloned slot owns one count — same contract as engine components. expect(cs.texture).eq(texture); - expect(texture.refCount).eq(baseline); - - const recloned = cloned.clone(); - expect(texture.refCount).eq(baseline); + expect(texture.refCount).eq(2); + // Releasing on destroy is the script class's responsibility (onDestroy → setter -1). cloned.destroy(); - recloned.destroy(); - expect(texture.refCount).eq(baseline); + expect(texture.refCount).eq(1); + + parent.destroy(); + expect(texture.refCount).eq(0); rootEntity.destroy(); texture.destroy(); From c7df2af87531c351ee2b010476cea3c182dd25cc Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 14:47:07 +0800 Subject: [PATCH 019/109] refactor(clone): move slot-ownership acquisition out of the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _cloneValue is pure cloning again (4 params, Assignment = plain share). ComponentCloner — the only place slots own references — detects the share (result === source value, registered resource only; remapped/deep results never match) and acquires through CloneManager._acquireSlotOwnership. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 49 +++++++++++----------- packages/core/src/clone/ComponentCloner.ts | 8 +++- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 40cf323e33..be62b14952 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -148,13 +148,8 @@ export class CloneManager { /** * @internal * Clone gate — decides how to clone one value based on its type. - * - * `refs` controls the ref-count contract for THIS slot: - * - `undefined` — the slot owns no count (container elements, plain-object fields). - * - `null` — a component top-level field: an Assignment-shared ref-counted resource gains +1 - * here, and the owning class's destroy path releases it (implementation contract). */ - static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode, refs?: null): any { + static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { if (!(value instanceof Object)) return value; // Functions: an explicit field decorator (@assignmentClone/@deepClone) shares the reference; // by default keep the clone's own binding when its slot already holds one (constructor-rebound @@ -179,29 +174,33 @@ export class CloneManager { } if (cloneMode === CloneMode.Ignore) return reuse; - if (cloneMode === CloneMode.Assignment) { - // Slot-ownership contract: an engine-component field sharing an explicitly-registered - // ref-counted resource (ReferResource family — excludes duck-typed counters like Shader) - // owns one reference; the owning component's destroy path releases it. - if (refs !== undefined && CloneManager._isCountedResource(value)) { - if (CloneManager._isCountedResource(reuse)) { - const presetRefCount = (<{ refCount?: number }>reuse).refCount; - presetRefCount !== undefined && - presetRefCount <= 0 && - Logger.error( - `CloneManager: the clone's preset ${reuse.constructor.name} holds no owned reference; ` + - `a constructor presetting a ref-counted resource must acquire it (assign via its setter or an explicit +1).` - ); - (reuse)._addReferCount(-1); - } - (value)._addReferCount(1); - } - return value; - } + if (cloneMode === CloneMode.Assignment) return value; if (cloneMode === CloneMode.Remap) return cloneMap.get(value) ?? value; return CloneManager._deepClone(value, reuse, cloneMap); } + /** + * @internal + * Slot-ownership acquisition for a component top-level slot that shared a ref-counted resource + * (only types explicitly registered `@defaultCloneMode(Assignment)`, i.e. the ReferResource + * family — excludes duck-typed counters like Shader): +1 on the shared value, -1 on a replaced + * owned preset. Releasing the acquired count on destroy is the owning class's contract. + */ + static _acquireSlotOwnership(value: any, preset: any): void { + if (!CloneManager._isCountedResource(value)) return; + if (CloneManager._isCountedResource(preset)) { + const presetRefCount = (<{ refCount?: number }>preset).refCount; + presetRefCount !== undefined && + presetRefCount <= 0 && + Logger.error( + `CloneManager: 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); + } + (value)._addReferCount(1); + } + /** * Whether the value participates in the slot-ownership ref-count contract: only types * explicitly registered `@defaultCloneMode(Assignment)` (the ReferResource family) do. diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 283ed613f9..0cc7da038d 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -36,9 +36,13 @@ export class ComponentCloner { for (const k in source) { const fieldMode = fieldModes.get(k); if (fieldMode === CloneMode.Ignore) continue; + const sourceValue = source[k]; + const preset = target[k]; // Field decorator (highest) → value-shape / type default (Entity/Component remap via the map). - // Component top-level slots own their shared ref-counted resources (`refs: null`). - target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode, null); + const cloned = (target[k] = CloneManager._cloneValue(sourceValue, preset, cloneMap, fieldMode)); + // A slot that shared the source's ref-counted resource owns one reference (returned as-is + // only by the Assignment path for registered resources; remapped/deep values never match). + cloned === sourceValue && CloneManager._acquireSlotOwnership(cloned, preset); } ((source as unknown))._cloneTo?.(target, cloneMap); } From 84e1b766c0c2ea0afc6c0f45eb482aa51ba09122 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 14:57:59 +0800 Subject: [PATCH 020/109] docs(clone): clarify the @deepClone-on-Remap branch is error recovery Not a priority rule: deep-copying an Entity/Component is an unexecutable directive, so it recovers to remap with a warning. Every executable decorator still wins over the type default. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index be62b14952..d0620dddc4 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -165,8 +165,11 @@ export class CloneManager { ? CloneMode.Deep : ((value)._defaultCloneMode ?? CloneMode.Assignment); } else if (cloneMode === CloneMode.Deep && (value)._defaultCloneMode === CloneMode.Remap) { - // Entity/Component instances cannot be deep cloned (engine-bound constructors, live scene - // state); a @deepClone-decorated reference falls back to remap for reference correctness. + // Error recovery, NOT a priority rule: `@deepClone` on an Entity/Component reference is an + // unexecutable directive — deep-copying one would `new Entity()` without an engine and + // field-walk live scene state into a corrupt detached object. Recover to the closest + // executable semantics (remap) and warn. Every executable decorator still wins over the + // type default at all depths. Logger.warn( `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` ); From 3c26c2a3d6386d2dec389a60d4df001213e7cc07 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 15:06:48 +0800 Subject: [PATCH 021/109] docs(clone): condense the CloneManager class comment Keep the model summary and the ref-count contract in two short paragraphs; per-type behavior and deep-clone stages live on the methods themselves. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 31 ++++++------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index d0620dddc4..5ed44d202e 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -64,31 +64,14 @@ export function defaultCloneMode(mode: CloneMode) { * @internal * Clone manager. * - * Opt-out model: all enumerable fields of an object are cloned unless marked `@ignoreClone`. - * HOW each field value is cloned depends on the value's runtime type (`@defaultCloneMode`): - * - primitive / null / undefined → assign by value. - * - function → keep the clone's own binding when its slot already holds one (constructor-rebound - * handlers), otherwise share the reference (container elements have no own slot value). - * - Remap (Entity / Component) → resolve to the clone via the identity map. - * - Assignment (ReferResource / unknown types without @defaultCloneMode) → share the reference. - * - Deep (@defaultCloneMode(Deep) / copyFrom types / containers) → recursively deep clone. + * Opt-out model: every enumerable field is cloned unless `@ignoreClone`. How a value clones is + * decided by the gate (`_cloneValue`): field decorator → container deep → the value type's + * `@defaultCloneMode` → Assignment (share) fallback. * - * Ref-count (slot-ownership contract): every COMPONENT top-level field holding an explicitly - * registered ref-counted resource (ReferResource family) owns one reference. The gate acquires - * it when cloning the slot (+1, and -1 on a replaced preset); releasing it on destroy is the - * component class's responsibility — engine components do it in their destroy paths, script - * authors in `onDestroy`. A class that doesn't release is a bug in that class. Everything below - * the top level owns nothing at the gate: container elements and plain-object fields are plain - * shares, and a nested class that ref-counts its resources pairs the acquisition itself - * (`_cloneTo` +1 / destroy -1 in the same class). Slots rebuilt through setters must be - * `@ignoreClone` so the setter is the single +1 source (e.g. `MeshRenderer.mesh`). - * - * Deep clone lifecycle (3-stage): - * 1. Construct — reuse the clone's existing slot value if same type, else `new ctor()`. - * 2. Populate — `copyFrom` (value-type fast path) OR recurse all fields (opt-out). - * 3. Finalize — `_cloneTo` post-clone hook for native sync / derived state rebuild. - * - * Cycles / shared sub-graphs dedup through the identity map. + * Ref count: a component top-level slot sharing a registered resource owns one reference + * (`_acquireSlotOwnership`); releasing it on destroy is the owning class's contract. Below the + * top level the gate never counts — a nested class pairs its own acquisition, and setter-rebuilt + * slots stay `@ignoreClone` so the setter is the single +1 source. */ export class CloneManager { /** @internal Own field-level clone modes per class (excluding inherited), from `@deepClone`/`@assignmentClone`/`@ignoreClone`. */ From 5b8da5a3183d841eb129a32f2f9361e0c648d141 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 15:14:35 +0800 Subject: [PATCH 022/109] =?UTF-8?q?test(clone):=20pin=20aliasing=20topolog?= =?UTF-8?q?y=20=E2=80=94=20shared=20instance=20clones=20into=20one=20share?= =?UTF-8?q?d=20clone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [vec3, vec3, vec3] must clone into one NEW Vector3 referenced three times (identity-map dedup), not three copies and not the source instance. Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneUtils.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index bb05863ca1..f22d9e4323 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -8,6 +8,7 @@ import { deepClone, ignoreClone } from "@galacean/engine-core"; +import { Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { describe, expect, it } from "vitest"; @@ -895,6 +896,32 @@ describe("Clone remap", async () => { }); }); + 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(); + }); + }); + describe("Binary data fields", () => { it("DataView field clones by bytes without crashing", () => { const rootEntity = scene.createRootEntity("root"); From e5894ae424bf387774e5fdc7ed387fc84bcfad45 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 15:31:44 +0800 Subject: [PATCH 023/109] refactor(clone): drop _cloneTo logic the type-driven gate already covers - SubEmittersModule._cloneTo deleted: SubEmitter._module back-link is remapped by the identity map (the owning module registers before its sub-emitters deep-clone), no manual re-link needed. - MainModule: maxParticles hand-copy deleted; _maxParticleBuffer is a plain number the field walk copies (the setter is pure arithmetic). - SkinnedMeshRenderer: _blendShapeWeights hand-slice deleted; the gate byte-copies typed arrays. Kept _cloneTo hooks fall into four irreplaceable duties: setter as the single +1 source (mesh/sprite/materials), getter-settled values (Transform, DynamicCollider), runtime-state rebuild (_syncNative, transform feedback, update flags), and class-local ref counting (SpriteTransition, ShaderData). Co-Authored-By: Claude Fable 5 --- packages/core/src/mesh/SkinnedMeshRenderer.ts | 5 +---- packages/core/src/particle/modules/MainModule.ts | 3 --- packages/core/src/particle/modules/SubEmitter.ts | 3 +-- .../core/src/particle/modules/SubEmittersModule.ts | 11 ----------- 4 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts index 53d2cbd4b7..3caf0576e4 100644 --- a/packages/core/src/mesh/SkinnedMeshRenderer.ts +++ b/packages/core/src/mesh/SkinnedMeshRenderer.ts @@ -33,7 +33,6 @@ export class SkinnedMeshRenderer extends MeshRenderer { @ignoreClone private _jointDataCreateCache: Vector2 = new Vector2(-1, -1); - @ignoreClone private _blendShapeWeights: Float32Array; @ignoreClone private _maxVertexUniformVectors: number; @@ -148,8 +147,6 @@ export class SkinnedMeshRenderer extends MeshRenderer { if (this.skin) { target._applySkin(null, target.skin); } - - this._blendShapeWeights && (target._blendShapeWeights = this._blendShapeWeights.slice()); } protected override _update(context: RenderContext): void { @@ -254,7 +251,7 @@ export class SkinnedMeshRenderer extends MeshRenderer { } @ignoreClone - private _onSkinUpdated(type: SkinUpdateFlag, value: Object): void { + private _onSkinUpdated(type: SkinUpdateFlag, value: object): void { switch (type) { case SkinUpdateFlag.BoneCountChanged: const shaderData = this.shaderData; diff --git a/packages/core/src/particle/modules/MainModule.ts b/packages/core/src/particle/modules/MainModule.ts index 74b148e9d4..f0b1aa6e4d 100644 --- a/packages/core/src/particle/modules/MainModule.ts +++ b/packages/core/src/particle/modules/MainModule.ts @@ -56,7 +56,6 @@ export class MainModule implements ICustomClone { playOnEnabled = true; /** @internal */ - @ignoreClone _maxParticleBuffer = 1000; /** @internal */ @ignoreClone @@ -323,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/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index 083961f525..a893371b48 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -20,8 +20,7 @@ export class SubEmitter { /** Number of sub particles emitted per parent event. */ emitCount: number = 1; - /** @internal */ - @ignoreClone + /** @internal Back-link; the clone gate remaps it through the identity map (the owning module is registered before its sub-emitters deep-clone). */ _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 9e6c3564e6..de22030aaa 100644 --- a/packages/core/src/particle/modules/SubEmittersModule.ts +++ b/packages/core/src/particle/modules/SubEmittersModule.ts @@ -167,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 */ From 8ef2a64000efa98b2ce5faf8b26f635d62dc34a9 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 15:37:32 +0800 Subject: [PATCH 024/109] fix(mesh): type _onSkinUpdated's value as the union it actually carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lint-staged's eslint --fix rewrote the legacy `Object` param to `object`, breaking the loose casts tsc-wise (b:types). The value is genuinely `number | Entity` depending on the SkinUpdateFlag — type it as such instead of restoring the loose cast. Co-Authored-By: Claude Fable 5 --- packages/core/src/mesh/SkinnedMeshRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts index 3caf0576e4..9dc04e9538 100644 --- a/packages/core/src/mesh/SkinnedMeshRenderer.ts +++ b/packages/core/src/mesh/SkinnedMeshRenderer.ts @@ -251,7 +251,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; From 158fbdce87dd5df258b6fc47c9bca25f8a8478f7 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 15:50:19 +0800 Subject: [PATCH 025/109] refactor(math): add a MathValue base class carrying the family clone mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 13 value-semantic math types extend the new empty `MathValue` base, and core registers `@defaultCloneMode(Deep)` once on it instead of enumerating every type — new math types inherit the clone semantics by construction, closing the "forgot to register in core" gap. Construction hot-path checked: with a warmed JIT the empty super() call is within measurement noise (<1% over 1e7 constructions, best-of-5). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 40 ++-------- packages/math/src/BoundingBox.ts | 4 +- packages/math/src/BoundingFrustum.ts | 7 +- packages/math/src/BoundingSphere.ts | 4 +- packages/math/src/CollisionUtil.ts | 2 +- packages/math/src/Color.ts | 4 +- packages/math/src/MathValue.ts | 8 ++ packages/math/src/Matrix.ts | 98 ++++++++++++------------ packages/math/src/Matrix3x3.ts | 6 +- packages/math/src/Plane.ts | 4 +- packages/math/src/Quaternion.ts | 4 +- packages/math/src/Rect.ts | 4 +- packages/math/src/SphericalHarmonics3.ts | 2 + packages/math/src/Vector2.ts | 4 +- packages/math/src/Vector3.ts | 4 +- packages/math/src/Vector4.ts | 6 +- packages/math/src/index.ts | 1 + 17 files changed, 106 insertions(+), 96 deletions(-) create mode 100644 packages/math/src/MathValue.ts diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 5ed44d202e..9661b8abc4 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,18 +1,4 @@ -import { - BoundingBox, - BoundingFrustum, - BoundingSphere, - Color, - Matrix, - Matrix3x3, - Plane, - Quaternion, - Rect, - SphericalHarmonics3, - Vector2, - Vector3, - Vector4 -} from "@galacean/engine-math"; +import { MathValue } from "@galacean/engine-math"; import { IReferable } from "../asset/IReferable"; import { TypedArray } from "../base/Constant"; import { Logger } from "../base/Logger"; @@ -294,22 +280,8 @@ export class CloneManager { } } -// Built-in default clone mode for math value types. The math package cannot depend on core's -// `@defaultCloneMode`, so they are registered here instead (core → math is the normal dependency -// direction). All are value-semantic and always deep cloned. -const _markDeep = defaultCloneMode(CloneMode.Deep); -[ - Vector2, - Vector3, - Vector4, - Quaternion, - Matrix, - Matrix3x3, - Color, - Rect, - BoundingBox, - BoundingFrustum, - BoundingSphere, - Plane, - SphericalHarmonics3 -].forEach((type) => _markDeep(type)); +// Built-in default clone mode for the whole math value-type family (Vector*, Matrix*, Color, +// bounds, ...): every math value type extends `MathValue`, so one registration on the base class +// covers them all via the prototype chain. Registered here because the math package cannot +// depend on core's `@defaultCloneMode` (core → math is the normal dependency direction). +defaultCloneMode(CloneMode.Deep)(MathValue); diff --git a/packages/math/src/BoundingBox.ts b/packages/math/src/BoundingBox.ts index da387edaca..cb9fce8b5d 100644 --- a/packages/math/src/BoundingBox.ts +++ b/packages/math/src/BoundingBox.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { BoundingSphere } from "./BoundingSphere"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; @@ -7,7 +8,7 @@ import { Vector3, Vector3Like } from "./Vector3"; /** * Axis Aligned Bound Box (AABB). */ -export class BoundingBox implements IClone, ICopy { +export class BoundingBox extends MathValue implements IClone, ICopy { /** * Calculate a bounding box from the center point and the extent of the bounding box. * @param center - The center point @@ -130,6 +131,7 @@ export class BoundingBox implements IClone, ICopy, ICopy { +export class BoundingFrustum + extends MathValue + implements IClone, ICopy +{ /** The near plane of this frustum. */ public near: Plane; /** The far plane of this frustum. */ @@ -30,6 +34,7 @@ export class BoundingFrustum implements IClone, ICopy, ICopy { +export class BoundingSphere extends MathValue implements IClone, ICopy { private static _tempVec30: Vector3 = new Vector3(); /** @@ -67,6 +68,7 @@ export class BoundingSphere implements IClone, ICopy, ICopy { +export class Color extends MathValue implements IClone, ICopy { /** * Modify a value from the sRGB space to the linear space. * @param value - The value in sRGB space @@ -184,6 +185,7 @@ export class Color implements IClone, ICopy { * @param a - The alpha component of the color */ constructor(r: number = 1, g: number = 1, b: number = 1, a: number = 1) { + super(); this._r = r; this._g = g; this._b = b; diff --git a/packages/math/src/MathValue.ts b/packages/math/src/MathValue.ts new file mode 100644 index 0000000000..502ed17622 --- /dev/null +++ b/packages/math/src/MathValue.ts @@ -0,0 +1,8 @@ +/** + * Base class of value-semantic math types (vectors, matrices, quaternion, color, rect, bounds…). + * + * Carries no state or behavior; it exists so family-wide traits can be declared once on the + * prototype chain — e.g. the engine registers the default clone mode (deep) on it for all math + * value types in a single place. + */ +export abstract class MathValue {} diff --git a/packages/math/src/Matrix.ts b/packages/math/src/Matrix.ts index 92afa3f79e..03243c14db 100644 --- a/packages/math/src/Matrix.ts +++ b/packages/math/src/Matrix.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -8,7 +9,7 @@ import { Vector3 } from "./Vector3"; /** * Represents a 4x4 mathematical matrix. */ -export class Matrix implements IClone, ICopy { +export class Matrix extends MathValue implements IClone, ICopy { private static readonly _tempVec30: Vector3 = new Vector3(); private static readonly _tempVec31: Vector3 = new Vector3(); private static readonly _tempVec32: Vector3 = new Vector3(); @@ -206,19 +207,19 @@ export class Matrix implements IClone, ICopy { static rotationQuaternion(quaternion: Quaternion, out: Matrix): void { const oe = out.elements; const { _x: x, _y: y, _z: z, _w: w } = quaternion; - let x2 = x + x; - let y2 = y + y; - let z2 = z + z; - - let xx = x * x2; - let yx = y * x2; - let yy = y * y2; - let zx = z * x2; - let zy = z * y2; - let zz = z * z2; - let wx = w * x2; - let wy = w * y2; - let wz = w * z2; + const x2 = x + x; + const y2 = y + y; + const z2 = z + z; + + const xx = x * x2; + const yx = y * x2; + const yy = y * y2; + const zx = z * x2; + const zy = z * y2; + const zz = z * z2; + const wx = w * x2; + const wy = w * y2; + const wz = w * z2; oe[0] = 1 - yy - zz; oe[1] = yx + wz; @@ -313,22 +314,22 @@ export class Matrix implements IClone, ICopy { static affineTransformation(scale: Vector3, rotation: Quaternion, translation: Vector3, out: Matrix): void { const oe = out.elements; const { _x: x, _y: y, _z: z, _w: w } = rotation; - let x2 = x + x; - let y2 = y + y; - let z2 = z + z; - - let xx = x * x2; - let xy = x * y2; - let xz = x * z2; - let yy = y * y2; - let yz = y * z2; - let zz = z * z2; - let wx = w * x2; - let wy = w * y2; - let wz = w * z2; - let sx = scale._x; - let sy = scale._y; - let sz = scale._z; + const x2 = x + x; + const y2 = y + y; + const z2 = z + z; + + const xx = x * x2; + const xy = x * y2; + const xz = x * z2; + const yy = y * y2; + const yz = y * z2; + const zz = z * z2; + const wx = w * x2; + const wy = w * y2; + const wz = w * z2; + const sx = scale._x; + const sy = scale._y; + const sz = scale._z; oe[0] = (1 - (yy + zz)) * sx; oe[1] = (xy + wz) * sx; @@ -612,29 +613,29 @@ export class Matrix implements IClone, ICopy { c = Math.cos(r); t = 1 - c; - let a11 = me[0], + const a11 = me[0], a12 = me[1], a13 = me[2], a14 = me[3]; - let a21 = me[4], + const a21 = me[4], a22 = me[5], a23 = me[6], a24 = me[7]; - let a31 = me[8], + const a31 = me[8], a32 = me[9], a33 = me[10], a34 = me[11]; // Construct the elements of the rotation matrix - let b11 = x * x * t + c; - let b12 = y * x * t + z * s; - let b13 = z * x * t - y * s; - let b21 = x * y * t - z * s; - let b22 = y * y * t + c; - let b23 = z * y * t + x * s; - let b31 = x * z * t + y * s; - let b32 = y * z * t - x * s; - let b33 = z * z * t + c; + const b11 = x * x * t + c; + const b12 = y * x * t + z * s; + const b13 = z * x * t - y * s; + const b21 = x * y * t - z * s; + const b22 = y * y * t + c; + const b23 = z * y * t + x * s; + const b31 = x * z * t + y * s; + const b32 = y * z * t - x * s; + const b33 = z * z * t + c; // Perform rotation-specific matrix multiplication oe[0] = a11 * b11 + a21 * b12 + a31 * b13; @@ -838,6 +839,7 @@ export class Matrix implements IClone, ICopy { m43: number = 0, m44: number = 1 ) { + super(); const e: Float32Array = this.elements; e[0] = m11; @@ -1038,28 +1040,28 @@ export class Matrix implements IClone, ICopy { */ getRotation(out: Quaternion): Quaternion { const e = this.elements; - let trace = e[0] + e[5] + e[10]; + const trace = e[0] + e[5] + e[10]; if (trace > MathUtil.zeroTolerance) { - let s = Math.sqrt(trace + 1.0) * 2; + const s = Math.sqrt(trace + 1.0) * 2; out._w = 0.25 * s; out._x = (e[6] - e[9]) / s; out._y = (e[8] - e[2]) / s; out._z = (e[1] - e[4]) / s; } else if (e[0] > e[5] && e[0] > e[10]) { - let s = Math.sqrt(1.0 + e[0] - e[5] - e[10]) * 2; + const s = Math.sqrt(1.0 + e[0] - e[5] - e[10]) * 2; out._w = (e[6] - e[9]) / s; out._x = 0.25 * s; out._y = (e[1] + e[4]) / s; out._z = (e[8] + e[2]) / s; } else if (e[5] > e[10]) { - let s = Math.sqrt(1.0 + e[5] - e[0] - e[10]) * 2; + const s = Math.sqrt(1.0 + e[5] - e[0] - e[10]) * 2; out._w = (e[8] - e[2]) / s; out._x = (e[1] + e[4]) / s; out._y = 0.25 * s; out._z = (e[6] + e[9]) / s; } else { - let s = Math.sqrt(1.0 + e[10] - e[0] - e[5]) * 2; + const s = Math.sqrt(1.0 + e[10] - e[0] - e[5]) * 2; out._w = (e[1] - e[4]) / s; out._x = (e[8] + e[2]) / s; out._y = (e[6] + e[9]) / s; @@ -1193,7 +1195,7 @@ export class Matrix implements IClone, ICopy { */ clone(): Matrix { const e = this.elements; - let ret = new Matrix( + const ret = new Matrix( e[0], e[1], e[2], diff --git a/packages/math/src/Matrix3x3.ts b/packages/math/src/Matrix3x3.ts index cf98b16327..b9dc7fd01c 100644 --- a/packages/math/src/Matrix3x3.ts +++ b/packages/math/src/Matrix3x3.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -8,7 +9,7 @@ import { Vector2 } from "./Vector2"; /** * Represents a 3x3 mathematical matrix. */ -export class Matrix3x3 implements IClone, ICopy { +export class Matrix3x3 extends MathValue implements IClone, ICopy { /** * Determines the sum of two matrices. * @param left - The first matrix to add @@ -488,6 +489,7 @@ export class Matrix3x3 implements IClone, ICopy m32: number = 0, m33: number = 1 ) { + super(); const e: Float32Array = this.elements; e[0] = m11; @@ -674,7 +676,7 @@ export class Matrix3x3 implements IClone, ICopy */ clone(): Matrix3x3 { const e = this.elements; - let ret = new Matrix3x3(e[0], e[1], e[2], e[3], e[4], e[5], e[6], e[7], e[8]); + const ret = new Matrix3x3(e[0], e[1], e[2], e[3], e[4], e[5], e[6], e[7], e[8]); return ret; } diff --git a/packages/math/src/Plane.ts b/packages/math/src/Plane.ts index e9d0ef6f2a..fd0aa33602 100644 --- a/packages/math/src/Plane.ts +++ b/packages/math/src/Plane.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { Vector3 } from "./Vector3"; @@ -5,7 +6,7 @@ import { Vector3 } from "./Vector3"; /** * Represents a plane in three-dimensional space. */ -export class Plane implements IClone, ICopy { +export class Plane extends MathValue implements IClone, ICopy { /** * Normalize the normal vector of the specified plane. * @param p - The specified plane @@ -64,6 +65,7 @@ export class Plane implements IClone, ICopy { * @param distance - The distance of the plane along its normal to the origin */ constructor(normal: Vector3 = null, distance: number = 0) { + super(); normal && this.normal.copyFrom(normal); this.distance = distance; } diff --git a/packages/math/src/Quaternion.ts b/packages/math/src/Quaternion.ts index e87669c1c7..36218d71cd 100644 --- a/packages/math/src/Quaternion.ts +++ b/packages/math/src/Quaternion.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -7,7 +8,7 @@ import { Vector3 } from "./Vector3"; /** * Represents a four dimensional mathematical quaternion. */ -export class Quaternion implements IClone, ICopy { +export class Quaternion extends MathValue implements IClone, ICopy { /** @internal */ static readonly _tempVector3 = new Vector3(); /** @internal */ @@ -490,6 +491,7 @@ export class Quaternion implements IClone, ICopy, ICopy { +export class Rect extends MathValue implements IClone, ICopy { /** @internal */ _x: number; /** @internal */ @@ -70,6 +71,7 @@ export class Rect implements IClone, ICopy { * @param height - The height of the rectangle, measured from the y position, default 0 */ constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) { + super(); this._x = x; this._y = y; this._width = width; diff --git a/packages/math/src/SphericalHarmonics3.ts b/packages/math/src/SphericalHarmonics3.ts index 741837cd09..023a3ddbfc 100644 --- a/packages/math/src/SphericalHarmonics3.ts +++ b/packages/math/src/SphericalHarmonics3.ts @@ -1,6 +1,7 @@ import { Color } from "./Color"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; +import { MathValue } from "./MathValue"; import { Vector3 } from "./Vector3"; /** @@ -11,6 +12,7 @@ import { Vector3 } from "./Vector3"; * https://google.github.io/filament/Filament.md.html#annex/sphericalharmonics */ export class SphericalHarmonics3 + extends MathValue implements IClone, ICopy { /** The coefficients of SphericalHarmonics3. */ diff --git a/packages/math/src/Vector2.ts b/packages/math/src/Vector2.ts index 5d6de38a98..0df82dba35 100644 --- a/packages/math/src/Vector2.ts +++ b/packages/math/src/Vector2.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -5,7 +6,7 @@ import { MathUtil } from "./MathUtil"; /** * Describes a 2D-vector. */ -export class Vector2 implements IClone, ICopy { +export class Vector2 extends MathValue implements IClone, ICopy { /** @internal */ static readonly _zero = new Vector2(0.0, 0.0); /** @internal */ @@ -217,6 +218,7 @@ export class Vector2 implements IClone, ICopy { * @param y - The y component of the vector, default 0 */ constructor(x: number = 0, y: number = 0) { + super(); this._x = x; this._y = y; } diff --git a/packages/math/src/Vector3.ts b/packages/math/src/Vector3.ts index 59e64cf14e..e736d4f049 100644 --- a/packages/math/src/Vector3.ts +++ b/packages/math/src/Vector3.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -8,7 +9,7 @@ import { Vector4 } from "./Vector4"; /** * Describes a 3D-vector. */ -export class Vector3 implements IClone, ICopy { +export class Vector3 extends MathValue implements IClone, ICopy { /** @internal */ static readonly _zero = new Vector3(0.0, 0.0, 0.0); /** @internal */ @@ -364,6 +365,7 @@ export class Vector3 implements IClone, ICopy { * @param z - The z component of the vector, default 0 */ constructor(x: number = 0, y: number = 0, z: number = 0) { + super(); this._x = x; this._y = y; this._z = z; diff --git a/packages/math/src/Vector4.ts b/packages/math/src/Vector4.ts index cf6c98fcc3..02078c6ac1 100644 --- a/packages/math/src/Vector4.ts +++ b/packages/math/src/Vector4.ts @@ -1,3 +1,4 @@ +import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -7,7 +8,7 @@ import { Quaternion } from "./Quaternion"; /** * Describes a 4D-vector. */ -export class Vector4 implements IClone, ICopy { +export class Vector4 extends MathValue implements IClone, ICopy { /** @internal */ static readonly _zero = new Vector4(0.0, 0.0, 0.0, 0.0); /** @internal */ @@ -321,6 +322,7 @@ export class Vector4 implements IClone, ICopy { * @param w - The w component of the vector, default 0 */ constructor(x: number = 0, y: number = 0, z: number = 0, w: number = 0) { + super(); this._x = x; this._y = y; this._z = z; @@ -459,7 +461,7 @@ export class Vector4 implements IClone, ICopy { * @returns A clone of this vector */ clone(): Vector4 { - let ret = new Vector4(this._x, this._y, this._z, this._w); + const ret = new Vector4(this._x, this._y, this._z, this._w); return ret; } diff --git a/packages/math/src/index.ts b/packages/math/src/index.ts index fb44f4f74a..73b9aa9588 100755 --- a/packages/math/src/index.ts +++ b/packages/math/src/index.ts @@ -5,6 +5,7 @@ export { BoundingSphere } from "./BoundingSphere"; export { BoundingBox } from "./BoundingBox"; export { BoundingFrustum } from "./BoundingFrustum"; export { MathUtil } from "./MathUtil"; +export { MathValue } from "./MathValue"; export { CollisionUtil } from "./CollisionUtil"; export { Matrix } from "./Matrix"; export { Matrix3x3 } from "./Matrix3x3"; From 1be50b06baa50fec15fa27950ca9a66f083dae0b Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 15:56:22 +0800 Subject: [PATCH 026/109] Revert "refactor(math): add a MathValue base class carrying the family clone mode" This reverts commit 158fbdce87dd5df258b6fc47c9bca25f8a8478f7. --- packages/core/src/clone/CloneManager.ts | 40 ++++++++-- packages/math/src/BoundingBox.ts | 4 +- packages/math/src/BoundingFrustum.ts | 7 +- packages/math/src/BoundingSphere.ts | 4 +- packages/math/src/CollisionUtil.ts | 2 +- packages/math/src/Color.ts | 4 +- packages/math/src/MathValue.ts | 8 -- packages/math/src/Matrix.ts | 98 ++++++++++++------------ packages/math/src/Matrix3x3.ts | 6 +- packages/math/src/Plane.ts | 4 +- packages/math/src/Quaternion.ts | 4 +- packages/math/src/Rect.ts | 4 +- packages/math/src/SphericalHarmonics3.ts | 2 - packages/math/src/Vector2.ts | 4 +- packages/math/src/Vector3.ts | 4 +- packages/math/src/Vector4.ts | 6 +- packages/math/src/index.ts | 1 - 17 files changed, 96 insertions(+), 106 deletions(-) delete mode 100644 packages/math/src/MathValue.ts diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 9661b8abc4..5ed44d202e 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,4 +1,18 @@ -import { MathValue } from "@galacean/engine-math"; +import { + BoundingBox, + BoundingFrustum, + BoundingSphere, + Color, + Matrix, + Matrix3x3, + Plane, + Quaternion, + Rect, + SphericalHarmonics3, + Vector2, + Vector3, + Vector4 +} from "@galacean/engine-math"; import { IReferable } from "../asset/IReferable"; import { TypedArray } from "../base/Constant"; import { Logger } from "../base/Logger"; @@ -280,8 +294,22 @@ export class CloneManager { } } -// Built-in default clone mode for the whole math value-type family (Vector*, Matrix*, Color, -// bounds, ...): every math value type extends `MathValue`, so one registration on the base class -// covers them all via the prototype chain. Registered here because the math package cannot -// depend on core's `@defaultCloneMode` (core → math is the normal dependency direction). -defaultCloneMode(CloneMode.Deep)(MathValue); +// Built-in default clone mode for math value types. The math package cannot depend on core's +// `@defaultCloneMode`, so they are registered here instead (core → math is the normal dependency +// direction). All are value-semantic and always deep cloned. +const _markDeep = defaultCloneMode(CloneMode.Deep); +[ + Vector2, + Vector3, + Vector4, + Quaternion, + Matrix, + Matrix3x3, + Color, + Rect, + BoundingBox, + BoundingFrustum, + BoundingSphere, + Plane, + SphericalHarmonics3 +].forEach((type) => _markDeep(type)); diff --git a/packages/math/src/BoundingBox.ts b/packages/math/src/BoundingBox.ts index cb9fce8b5d..da387edaca 100644 --- a/packages/math/src/BoundingBox.ts +++ b/packages/math/src/BoundingBox.ts @@ -1,4 +1,3 @@ -import { MathValue } from "./MathValue"; import { BoundingSphere } from "./BoundingSphere"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; @@ -8,7 +7,7 @@ import { Vector3, Vector3Like } from "./Vector3"; /** * Axis Aligned Bound Box (AABB). */ -export class BoundingBox extends MathValue implements IClone, ICopy { +export class BoundingBox implements IClone, ICopy { /** * Calculate a bounding box from the center point and the extent of the bounding box. * @param center - The center point @@ -131,7 +130,6 @@ export class BoundingBox extends MathValue implements IClone, ICopy * @param max - The maximum point of the box */ constructor(min: Vector3 = null, max: Vector3 = null) { - super(); min && this.min.copyFrom(min); max && this.max.copyFrom(max); } diff --git a/packages/math/src/BoundingFrustum.ts b/packages/math/src/BoundingFrustum.ts index ff80266bab..0014b7c58a 100644 --- a/packages/math/src/BoundingFrustum.ts +++ b/packages/math/src/BoundingFrustum.ts @@ -1,4 +1,3 @@ -import { MathValue } from "./MathValue"; import { BoundingBox } from "./BoundingBox"; import { BoundingSphere } from "./BoundingSphere"; import { CollisionUtil } from "./CollisionUtil"; @@ -12,10 +11,7 @@ import { Plane } from "./Plane"; /** * A bounding frustum. */ -export class BoundingFrustum - extends MathValue - implements IClone, ICopy -{ +export class BoundingFrustum implements IClone, ICopy { /** The near plane of this frustum. */ public near: Plane; /** The far plane of this frustum. */ @@ -34,7 +30,6 @@ export class BoundingFrustum * @param matrix - The view-projection matrix */ constructor(matrix: Matrix = null) { - super(); this.near = new Plane(); this.far = new Plane(); this.left = new Plane(); diff --git a/packages/math/src/BoundingSphere.ts b/packages/math/src/BoundingSphere.ts index c1f5cc36f3..adbac5bb1f 100644 --- a/packages/math/src/BoundingSphere.ts +++ b/packages/math/src/BoundingSphere.ts @@ -1,4 +1,3 @@ -import { MathValue } from "./MathValue"; import { BoundingBox } from "./BoundingBox"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; @@ -7,7 +6,7 @@ import { Vector3 } from "./Vector3"; /** * A bounding sphere. * */ -export class BoundingSphere extends MathValue implements IClone, ICopy { +export class BoundingSphere implements IClone, ICopy { private static _tempVec30: Vector3 = new Vector3(); /** @@ -68,7 +67,6 @@ export class BoundingSphere extends MathValue implements IClone, * @param radius - The radius of the sphere */ constructor(center: Vector3 = null, radius: number = 0) { - super(); center && this.center.copyFrom(center); this.radius = radius; } diff --git a/packages/math/src/CollisionUtil.ts b/packages/math/src/CollisionUtil.ts index bdf620138e..6cebe6d235 100644 --- a/packages/math/src/CollisionUtil.ts +++ b/packages/math/src/CollisionUtil.ts @@ -278,7 +278,7 @@ export class CollisionUtil { return -1; } - const discriminant = b * b - c; + let discriminant = b * b - c; if (discriminant < 0) { return -1; } diff --git a/packages/math/src/Color.ts b/packages/math/src/Color.ts index 5c728744b5..b369cf5d2b 100644 --- a/packages/math/src/Color.ts +++ b/packages/math/src/Color.ts @@ -1,4 +1,3 @@ -import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -6,7 +5,7 @@ import { MathUtil } from "./MathUtil"; /** * Describes a color in the from of RGBA (in order: R, G, B, A). */ -export class Color extends MathValue implements IClone, ICopy { +export class Color implements IClone, ICopy { /** * Modify a value from the sRGB space to the linear space. * @param value - The value in sRGB space @@ -185,7 +184,6 @@ export class Color extends MathValue implements IClone, ICopy, ICopy { +export class Matrix implements IClone, ICopy { private static readonly _tempVec30: Vector3 = new Vector3(); private static readonly _tempVec31: Vector3 = new Vector3(); private static readonly _tempVec32: Vector3 = new Vector3(); @@ -207,19 +206,19 @@ export class Matrix extends MathValue implements IClone, ICopy, ICopy, ICopy, ICopy, ICopy MathUtil.zeroTolerance) { - const s = Math.sqrt(trace + 1.0) * 2; + let s = Math.sqrt(trace + 1.0) * 2; out._w = 0.25 * s; out._x = (e[6] - e[9]) / s; out._y = (e[8] - e[2]) / s; out._z = (e[1] - e[4]) / s; } else if (e[0] > e[5] && e[0] > e[10]) { - const s = Math.sqrt(1.0 + e[0] - e[5] - e[10]) * 2; + let s = Math.sqrt(1.0 + e[0] - e[5] - e[10]) * 2; out._w = (e[6] - e[9]) / s; out._x = 0.25 * s; out._y = (e[1] + e[4]) / s; out._z = (e[8] + e[2]) / s; } else if (e[5] > e[10]) { - const s = Math.sqrt(1.0 + e[5] - e[0] - e[10]) * 2; + let s = Math.sqrt(1.0 + e[5] - e[0] - e[10]) * 2; out._w = (e[8] - e[2]) / s; out._x = (e[1] + e[4]) / s; out._y = 0.25 * s; out._z = (e[6] + e[9]) / s; } else { - const s = Math.sqrt(1.0 + e[10] - e[0] - e[5]) * 2; + let s = Math.sqrt(1.0 + e[10] - e[0] - e[5]) * 2; out._w = (e[1] - e[4]) / s; out._x = (e[8] + e[2]) / s; out._y = (e[6] + e[9]) / s; @@ -1195,7 +1193,7 @@ export class Matrix extends MathValue implements IClone, ICopy, ICopy { +export class Matrix3x3 implements IClone, ICopy { /** * Determines the sum of two matrices. * @param left - The first matrix to add @@ -489,7 +488,6 @@ export class Matrix3x3 extends MathValue implements IClone, ICopy, ICopy, ICopy { +export class Plane implements IClone, ICopy { /** * Normalize the normal vector of the specified plane. * @param p - The specified plane @@ -65,7 +64,6 @@ export class Plane extends MathValue implements IClone, ICopy, ICopy { +export class Quaternion implements IClone, ICopy { /** @internal */ static readonly _tempVector3 = new Vector3(); /** @internal */ @@ -491,7 +490,6 @@ export class Quaternion extends MathValue implements IClone, ICopy, ICopy { +export class Rect implements IClone, ICopy { /** @internal */ _x: number; /** @internal */ @@ -71,7 +70,6 @@ export class Rect extends MathValue implements IClone, ICopy { * @param height - The height of the rectangle, measured from the y position, default 0 */ constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) { - super(); this._x = x; this._y = y; this._width = width; diff --git a/packages/math/src/SphericalHarmonics3.ts b/packages/math/src/SphericalHarmonics3.ts index 023a3ddbfc..741837cd09 100644 --- a/packages/math/src/SphericalHarmonics3.ts +++ b/packages/math/src/SphericalHarmonics3.ts @@ -1,7 +1,6 @@ import { Color } from "./Color"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; -import { MathValue } from "./MathValue"; import { Vector3 } from "./Vector3"; /** @@ -12,7 +11,6 @@ import { Vector3 } from "./Vector3"; * https://google.github.io/filament/Filament.md.html#annex/sphericalharmonics */ export class SphericalHarmonics3 - extends MathValue implements IClone, ICopy { /** The coefficients of SphericalHarmonics3. */ diff --git a/packages/math/src/Vector2.ts b/packages/math/src/Vector2.ts index 0df82dba35..5d6de38a98 100644 --- a/packages/math/src/Vector2.ts +++ b/packages/math/src/Vector2.ts @@ -1,4 +1,3 @@ -import { MathValue } from "./MathValue"; import { IClone } from "./IClone"; import { ICopy } from "./ICopy"; import { MathUtil } from "./MathUtil"; @@ -6,7 +5,7 @@ import { MathUtil } from "./MathUtil"; /** * Describes a 2D-vector. */ -export class Vector2 extends MathValue implements IClone, ICopy { +export class Vector2 implements IClone, ICopy { /** @internal */ static readonly _zero = new Vector2(0.0, 0.0); /** @internal */ @@ -218,7 +217,6 @@ export class Vector2 extends MathValue implements IClone, ICopy, ICopy { +export class Vector3 implements IClone, ICopy { /** @internal */ static readonly _zero = new Vector3(0.0, 0.0, 0.0); /** @internal */ @@ -365,7 +364,6 @@ export class Vector3 extends MathValue implements IClone, ICopy, ICopy { +export class Vector4 implements IClone, ICopy { /** @internal */ static readonly _zero = new Vector4(0.0, 0.0, 0.0, 0.0); /** @internal */ @@ -322,7 +321,6 @@ export class Vector4 extends MathValue implements IClone, ICopy, ICopy Date: Fri, 3 Jul 2026 15:58:02 +0800 Subject: [PATCH 027/109] test(clone): guard math value-type registration completeness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MathValue base class is reverted: an empty class whose only consumer is core's clone registration doesn't belong in the math package's public shape. The gap it closed — forgetting to register a new math type — is a completeness constraint, expressed where it belongs: a test asserting every math export with copyFrom is registered Deep. Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneUtils.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index f22d9e4323..5f5cc7ea2a 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -1,4 +1,5 @@ import { + CloneMode, Entity, MeshRenderer, Script, @@ -896,6 +897,21 @@ describe("Clone remap", async () => { }); }); + describe("Math value-type registration completeness", () => { + it("every math export with copyFrom is registered @defaultCloneMode(Deep)", async () => { + const mathExports = await import("@galacean/engine-math"); + const unregistered: string[] = []; + for (const [name, exported] of Object.entries(mathExports)) { + if (typeof exported !== "function" || !(exported as any).prototype) continue; + if (typeof (exported as any).prototype.copyFrom !== "function") continue; + if ((exported as any).prototype._defaultCloneMode !== CloneMode.Deep) unregistered.push(name); + } + // A math value type missing from CloneManager's registration list falls back to + // Assignment sharing — mutable state silently shared between source and clone. + expect(unregistered).deep.eq([]); + }); + }); + describe("Aliasing topology", () => { it("one instance referenced three times clones into one instance referenced three times", () => { const rootEntity = scene.createRootEntity("root"); From 7dba7c12d2bda783ce73afc31c3c87ca2218dff3 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 16:06:40 +0800 Subject: [PATCH 028/109] feat(math): implement IClone/ICopy on Ray and register it Deep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ray (origin + direction) is plain value data like Plane and BoundingSphere but was the only family member missing copyFrom/clone — so it fell to Assignment sharing in the clone gate. The completeness guard caught it exactly as designed once copyFrom landed (red with ['Ray'] before registration, green after). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 2 ++ packages/math/src/Ray.ts | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 5ed44d202e..bc7c1c671a 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -7,6 +7,7 @@ import { Matrix3x3, Plane, Quaternion, + Ray, Rect, SphericalHarmonics3, Vector2, @@ -299,6 +300,7 @@ export class CloneManager { // direction). All are value-semantic and always deep cloned. const _markDeep = defaultCloneMode(CloneMode.Deep); [ + Ray, Vector2, Vector3, Vector4, 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; + } } From de460e8d40cc47217fc2284f21fbbe2b34d3b437 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 16:21:58 +0800 Subject: [PATCH 029/109] fix(clone): treat null-prototype objects as data containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layered gaps, found while probing whether deepCloneObject could absorb ShaderData.cloneTo: - the gate's primitive check used `instanceof Object`, which is false for `Object.create(null)` (and cross-realm objects) — they returned as-is, silently shared; the entry check now uses `typeof`. - `_isContainer` now classifies `constructor === undefined` as a plain data container, and the object branch rebuilds it via `Object.create(null)` instead of crashing on `new undefined()`. - deepCloneObject now respects the source type's @ignoreClone decorators (it silently bypassed them; both existing callers were unaffected by luck, not by design). Engine-internal null-proto maps were shielded by @ignoreClone by convention only; user-held `Object.create(null)` bags cloned as shared references before this fix. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 26 ++++++++++---- tests/src/core/CloneUtils.test.ts | 46 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index bc7c1c671a..e206ef5931 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -107,11 +107,16 @@ export class CloneManager { } /** - * Deep clone all enumerable fields of source into target through the clone gate. + * Deep clone all enumerable fields of source into target through the clone gate, + * respecting the source type's `@ignoreClone` field decorators. */ static deepCloneObject(source: object, target: object, cloneMap: Map): void { + const ctor = (<{ constructor?: Function }>source).constructor; + const fieldModes = ctor ? CloneManager.getFieldModes(ctor) : null; for (const k in source) { - target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap); + const fieldMode = fieldModes?.get(k); + if (fieldMode === CloneMode.Ignore) continue; + target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode); } } @@ -134,13 +139,15 @@ export class CloneManager { * Clone gate — decides how to clone one value based on its type. */ static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { - if (!(value instanceof Object)) return value; // Functions: an explicit field decorator (@assignmentClone/@deepClone) shares the reference; // by default keep the clone's own binding when its slot already holds one (constructor-rebound // handlers), otherwise share (container elements / null-preset slots have none). if (typeof value === "function") { return fieldMode !== undefined ? value : typeof reuse === "function" ? reuse : value; } + // Primitives copy by value. `typeof`, not `instanceof Object` — null-prototype objects + // (`Object.create(null)`) and cross-realm objects have no local Object.prototype in their chain. + if (value === null || typeof value !== "object") return value; // Mode priority: field decorator (highest) → container default deep → type's `@defaultCloneMode` → Assignment. let cloneMode = fieldMode; @@ -200,6 +207,8 @@ export class CloneManager { * Container-shape test — the single classification point shared by the gate and `_deepClone`. * Invariant: every shape this returns true for MUST have a dedicated branch in `_deepClone` * (ArrayBuffer view → byte copy, Array/Map/Set → per-element, plain object → field walk). + * `constructor === undefined` catches null-prototype objects (`Object.create(null)`) — data + * containers just like plain objects. */ private static _isContainer(value: object): boolean { return ( @@ -207,7 +216,8 @@ export class CloneManager { value instanceof Map || value instanceof Set || ArrayBuffer.isView(value) || - value.constructor === Object + value.constructor === Object || + value.constructor === undefined ); } @@ -278,15 +288,17 @@ export class CloneManager { } // Object — reuse or construct, then populate all fields (opt-out) + finalize. + // A null-prototype object (`Object.create(null)`) has no constructor: rebuild it as such. + const ctor = object>value.constructor; const dst = - reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); + reuse && reuse !== value && reuse.constructor === ctor ? reuse : ctor ? new ctor() : Object.create(null); cloneMap.set(value, dst); // Nested objects own no gate-acquired references: the slot-ownership contract covers // component top-level fields only. A nested class that ref-counts its resources pairs the // acquisition itself (`_cloneTo` +1 / destroy -1 in the same class, e.g. SpriteTransition). - const fieldModes = CloneManager.getFieldModes(value.constructor); + const fieldModes = ctor ? CloneManager.getFieldModes(ctor) : null; for (const key in value) { - const fieldMode = fieldModes.get(key); + const fieldMode = fieldModes?.get(key); if (fieldMode === CloneMode.Ignore) continue; dst[key] = CloneManager._cloneValue(value[key], dst[key], cloneMap, fieldMode); } diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index 5f5cc7ea2a..6d961e0fe4 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -912,6 +912,52 @@ describe("Clone remap", async () => { }); }); + 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("deepCloneObject decorator awareness", () => { + it("respects @ignoreClone on the source type's fields", async () => { + const { CloneManager } = await import("@galacean/engine-core"); + + class Bag { + kept = 1; + @ignoreClone + runtime = 1; + } + const source = new Bag(); + source.kept = 42; + source.runtime = 42; + const target = new Bag(); + + CloneManager.deepCloneObject(source, target, new Map()); + expect(target.kept).eq(42); + expect(target.runtime).eq(1); + }); + }); + describe("Aliasing topology", () => { it("one instance referenced three times clones into one instance referenced three times", () => { const rootEntity = scene.createRootEntity("root"); From 6c4cbb7456ed99e57f4c6f938484b42e9bc3a158 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 16:26:17 +0800 Subject: [PATCH 030/109] fix(clone): @deepClone on a registered asset recovers to sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetric to the Entity/Component recovery: deep-copying an engine-bound asset (ReferResource) through the generic object path would `new ctor()` without an engine — an unexecutable directive. Recover to the type's executable semantics (share, with the slot contract applying as usual) and warn, pointing at the asset's own clone() API for real copies. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 28 +++++++++++++++++-------- tests/src/core/CloneUtils.test.ts | 26 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index e206ef5931..a2a3188bdf 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -155,16 +155,26 @@ export class CloneManager { cloneMode = CloneManager._isContainer(value) ? CloneMode.Deep : ((value)._defaultCloneMode ?? CloneMode.Assignment); - } else if (cloneMode === CloneMode.Deep && (value)._defaultCloneMode === CloneMode.Remap) { - // Error recovery, NOT a priority rule: `@deepClone` on an Entity/Component reference is an - // unexecutable directive — deep-copying one would `new Entity()` without an engine and - // field-walk live scene state into a corrupt detached object. Recover to the closest - // executable semantics (remap) and warn. Every executable decorator still wins over the + } else if (cloneMode === CloneMode.Deep) { + // Error recovery, NOT a priority rule: `@deepClone` on an engine-bound instance is an + // unexecutable directive — the generic deep path would `new ctor()` without an engine and + // produce a corrupt detached object. Recover to the type's executable semantics and warn: + // Entity/Component → remap; registered assets (ReferResource) → share (copy an asset + // through its own clone() API instead). Every executable decorator still wins over the // type default at all depths. - Logger.warn( - `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` - ); - cloneMode = CloneMode.Remap; + const typeDefault = (value)._defaultCloneMode; + if (typeDefault === CloneMode.Remap) { + Logger.warn( + `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` + ); + cloneMode = CloneMode.Remap; + } else if (typeDefault === CloneMode.Assignment) { + Logger.warn( + `CloneManager: "${value.constructor.name}" is an engine-bound asset and cannot be deep cloned; ` + + `@deepClone on this field falls back to sharing (use the asset's own clone() API to copy it).` + ); + cloneMode = CloneMode.Assignment; + } } if (cloneMode === CloneMode.Ignore) return reuse; diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index 6d961e0fe4..c37bc0bad6 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -178,6 +178,12 @@ class DeepEntityRefScript extends Script { target: Entity; } +/** Script misusing @deepClone on an engine-bound asset (must fall back to sharing, never construct) */ +class DeepAssetRefScript extends Script { + @deepClone + texture: Texture2D; +} + /** Script with an @assignmentClone function field preset by the constructor */ class AssignedHandlerScript extends Script { @assignmentClone @@ -491,6 +497,26 @@ describe("Clone remap", async () => { rootEntity.destroy(); }); + it("@deepClone asset ref falls back to sharing instead of constructing a broken asset", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(DeepAssetRefScript); + const texture = new Texture2D(engine, 4, 4); + const baseline = texture.refCount; + script.texture = texture; + + const cloned = parent.clone(); + const cs = cloned.getComponent(DeepAssetRefScript); + + // Shared, never `new Texture2D()` without an engine; the slot behaves like an + // undecorated asset slot (owns one reference under the slot contract). + expect(cs.texture).eq(texture); + expect(texture.refCount).eq(baseline + 1); + + rootEntity.destroy(); + texture.destroy(true); + }); + it("@assignmentClone function field shares the source function (decorator wins over reuse)", () => { const rootEntity = scene.createRootEntity("root"); const parent = rootEntity.createChild("parent"); From 56fb595d44e68bd2e58eb2a1980d14551694ad40 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 16:50:34 +0800 Subject: [PATCH 031/109] docs(clone): tighten comments in CloneManager and ComponentCloner One line per constraint; drop restated behavior the code already shows. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 72 +++++++--------------- packages/core/src/clone/ComponentCloner.ts | 13 ++-- 2 files changed, 26 insertions(+), 59 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index a2a3188bdf..72fb38e6e5 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -43,16 +43,8 @@ export function ignoreClone(target: object, propertyKey: string): void { } /** - * Class decorator that sets the default clone mode for instances of the decorated type. - * - * When a field holds an instance of a type decorated with `@defaultCloneMode`, the clone system - * uses the specified mode instead of the default Assignment behavior. - * - * Built-in defaults: - * - Entity / Component → `CloneMode.Remap` - * - ReferResource (Texture, Mesh, Material, etc.) → `CloneMode.Assignment` - * - Value-semantic config objects (RenderState, ParticleModule, ColliderShape, etc.) → `CloneMode.Deep` - * + * Class decorator — the default clone mode for instances of the decorated type + * (unregistered types fall back to Assignment). * @param mode - The clone mode applied to instances of the decorated type */ export function defaultCloneMode(mode: CloneMode) { @@ -63,16 +55,12 @@ export function defaultCloneMode(mode: CloneMode) { /** * @internal - * Clone manager. - * - * Opt-out model: every enumerable field is cloned unless `@ignoreClone`. How a value clones is - * decided by the gate (`_cloneValue`): field decorator → container deep → the value type's - * `@defaultCloneMode` → Assignment (share) fallback. + * Clone manager. Opt-out model: every enumerable field is cloned unless `@ignoreClone`, + * with the mode resolved by `_cloneValue`. * - * Ref count: a component top-level slot sharing a registered resource owns one reference - * (`_acquireSlotOwnership`); releasing it on destroy is the owning class's contract. Below the - * top level the gate never counts — a nested class pairs its own acquisition, and setter-rebuilt - * slots stay `@ignoreClone` so the setter is the single +1 source. + * Ref count: a component top-level slot sharing a registered resource owns one reference; + * releasing it on destroy is the owning class's contract. Nested levels never count at the + * gate — a nested class pairs its own acquisition (`_cloneTo` +1 / destroy -1). */ export class CloneManager { /** @internal Own field-level clone modes per class (excluding inherited), from `@deepClone`/`@assignmentClone`/`@ignoreClone`. */ @@ -107,8 +95,7 @@ export class CloneManager { } /** - * Deep clone all enumerable fields of source into target through the clone gate, - * respecting the source type's `@ignoreClone` field decorators. + * Deep clone all enumerable fields of source into target, respecting `@ignoreClone`. */ static deepCloneObject(source: object, target: object, cloneMap: Map): void { const ctor = (<{ constructor?: Function }>source).constructor; @@ -139,14 +126,11 @@ export class CloneManager { * Clone gate — decides how to clone one value based on its type. */ static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { - // Functions: an explicit field decorator (@assignmentClone/@deepClone) shares the reference; - // by default keep the clone's own binding when its slot already holds one (constructor-rebound - // handlers), otherwise share (container elements / null-preset slots have none). + // Explicit decorator shares the function; default keeps the clone's own rebound binding. if (typeof value === "function") { return fieldMode !== undefined ? value : typeof reuse === "function" ? reuse : value; } - // Primitives copy by value. `typeof`, not `instanceof Object` — null-prototype objects - // (`Object.create(null)`) and cross-realm objects have no local Object.prototype in their chain. + // `typeof`, not `instanceof` — null-prototype / cross-realm objects lack local Object.prototype. if (value === null || typeof value !== "object") return value; // Mode priority: field decorator (highest) → container default deep → type's `@defaultCloneMode` → Assignment. @@ -156,12 +140,8 @@ export class CloneManager { ? CloneMode.Deep : ((value)._defaultCloneMode ?? CloneMode.Assignment); } else if (cloneMode === CloneMode.Deep) { - // Error recovery, NOT a priority rule: `@deepClone` on an engine-bound instance is an - // unexecutable directive — the generic deep path would `new ctor()` without an engine and - // produce a corrupt detached object. Recover to the type's executable semantics and warn: - // Entity/Component → remap; registered assets (ReferResource) → share (copy an asset - // through its own clone() API instead). Every executable decorator still wins over the - // type default at all depths. + // Error recovery (not a priority rule): engine-bound instances can't be deep cloned — + // recover to the type's executable mode (remap / share) and warn. const typeDefault = (value)._defaultCloneMode; if (typeDefault === CloneMode.Remap) { Logger.warn( @@ -185,10 +165,8 @@ export class CloneManager { /** * @internal - * Slot-ownership acquisition for a component top-level slot that shared a ref-counted resource - * (only types explicitly registered `@defaultCloneMode(Assignment)`, i.e. the ReferResource - * family — excludes duck-typed counters like Shader): +1 on the shared value, -1 on a replaced - * owned preset. Releasing the acquired count on destroy is the owning class's contract. + * A component top-level slot that shared a counted resource owns one reference: + * +1 on the shared value, -1 on a replaced owned preset; destroy releases it (class contract). */ static _acquireSlotOwnership(value: any, preset: any): void { if (!CloneManager._isCountedResource(value)) return; @@ -206,19 +184,16 @@ export class CloneManager { } /** - * Whether the value participates in the slot-ownership ref-count contract: only types - * explicitly registered `@defaultCloneMode(Assignment)` (the ReferResource family) do. + * Only explicitly registered Assignment types (ReferResource family) participate in + * ref counting — excludes duck-typed counters like Shader. */ private static _isCountedResource(value: any): boolean { return value instanceof Object && (value)._defaultCloneMode === CloneMode.Assignment; } /** - * Container-shape test — the single classification point shared by the gate and `_deepClone`. - * Invariant: every shape this returns true for MUST have a dedicated branch in `_deepClone` - * (ArrayBuffer view → byte copy, Array/Map/Set → per-element, plain object → field walk). - * `constructor === undefined` catches null-prototype objects (`Object.create(null)`) — data - * containers just like plain objects. + * The single container classification point. Invariant: every shape returning true MUST have + * a dedicated `_deepClone` branch. `constructor === undefined` = null-prototype objects. */ private static _isContainer(value: object): boolean { return ( @@ -297,15 +272,12 @@ export class CloneManager { return dst; } - // Object — reuse or construct, then populate all fields (opt-out) + finalize. - // A null-prototype object (`Object.create(null)`) has no constructor: rebuild it as such. + // Object — reuse or construct (null-prototype objects have no ctor: rebuild as such), + // then populate all fields (opt-out) + finalize. const ctor = object>value.constructor; const dst = reuse && reuse !== value && reuse.constructor === ctor ? reuse : ctor ? new ctor() : Object.create(null); cloneMap.set(value, dst); - // Nested objects own no gate-acquired references: the slot-ownership contract covers - // component top-level fields only. A nested class that ref-counts its resources pairs the - // acquisition itself (`_cloneTo` +1 / destroy -1 in the same class, e.g. SpriteTransition). const fieldModes = ctor ? CloneManager.getFieldModes(ctor) : null; for (const key in value) { const fieldMode = fieldModes?.get(key); @@ -317,9 +289,7 @@ export class CloneManager { } } -// Built-in default clone mode for math value types. The math package cannot depend on core's -// `@defaultCloneMode`, so they are registered here instead (core → math is the normal dependency -// direction). All are value-semantic and always deep cloned. +// Math value types are always deep cloned; registered here because math cannot depend on core. const _markDeep = defaultCloneMode(CloneMode.Deep); [ Ray, diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 0cc7da038d..b157a5b49c 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -8,14 +8,12 @@ import { CloneMode } from "./enums/CloneMode"; export interface ICustomClone { /** * @internal - * Default clone mode for instances of this type (set via `@defaultCloneMode`). - * Absence defaults to Assignment (shared reference). + * Type default set via `@defaultCloneMode`; absence means Assignment (share). */ readonly _defaultCloneMode?: CloneMode; /** * @internal - * Post-clone hook. `cloneMap` maps every source entity/component (and deep-cloned object) - * in the cloned subtree to its clone, for remapping references. + * Post-clone hook; `cloneMap` maps every source object in the cloned subtree to its clone. */ _cloneTo?(target: ICustomClone, cloneMap?: Map): void; /** @@ -29,7 +27,7 @@ export class ComponentCloner { * Clone component (opt-out: all fields cloned except @ignoreClone). * @param source - Clone source * @param target - Clone target - * @param cloneMap - Identity map of the cloned subtree (source entity/component → clone) + * @param cloneMap - Identity map of the cloned subtree (source object → clone) */ static cloneComponent(source: Component, target: Component, cloneMap: Map): void { const fieldModes = CloneManager.getFieldModes(source.constructor); @@ -38,10 +36,9 @@ export class ComponentCloner { if (fieldMode === CloneMode.Ignore) continue; const sourceValue = source[k]; const preset = target[k]; - // Field decorator (highest) → value-shape / type default (Entity/Component remap via the map). const cloned = (target[k] = CloneManager._cloneValue(sourceValue, preset, cloneMap, fieldMode)); - // A slot that shared the source's ref-counted resource owns one reference (returned as-is - // only by the Assignment path for registered resources; remapped/deep values never match). + // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path + // returns a registered resource as-is), so it owns one reference. cloned === sourceValue && CloneManager._acquireSlotOwnership(cloned, preset); } ((source as unknown))._cloneTo?.(target, cloneMap); From 6a8688ec5f60b8a93e008ef80862ca27fc0246cf Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 16:57:29 +0800 Subject: [PATCH 032/109] feat(clone): register runtime containers @defaultCloneMode(Ignore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DisorderedArray / SafeLoopArray / UpdateFlag join UpdateFlagManager as type-level Ignore: runtime containers keep the clone's own value even when a field forgets @ignoreClone — convention protection upgraded to mechanism protection. Every current field is decorated; this closes the gap for future ones. Co-Authored-By: Claude Fable 5 --- packages/core/src/UpdateFlag.ts | 5 ++++- packages/core/src/utils/DisorderedArray.ts | 3 +++ packages/core/src/utils/SafeLoopArray.ts | 4 ++++ tests/src/core/CloneUtils.test.ts | 23 ++++++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/core/src/UpdateFlag.ts b/packages/core/src/UpdateFlag.ts index d926b47b8c..ee3c1a9e80 100644 --- a/packages/core/src/UpdateFlag.ts +++ b/packages/core/src/UpdateFlag.ts @@ -1,9 +1,12 @@ +import { defaultCloneMode } from "./clone/CloneManager"; +import { CloneMode } from "./clone/enums/CloneMode"; import { UpdateFlagManager } from "./UpdateFlagManager"; import { Utils } from "./Utils"; /** * Used to update tags. */ +@defaultCloneMode(CloneMode.Ignore) export abstract class UpdateFlag { /** @internal */ _flagManagers: UpdateFlagManager[] = []; @@ -13,7 +16,7 @@ export abstract class UpdateFlag { * @param bit - Bit * @param param - Parameter */ - abstract dispatch(bit?: number, param?: Object): void; + abstract dispatch(bit?: number, param?: object): void; /** * Clear. diff --git a/packages/core/src/utils/DisorderedArray.ts b/packages/core/src/utils/DisorderedArray.ts index 81e7b8f8de..08ecef6c34 100644 --- a/packages/core/src/utils/DisorderedArray.ts +++ b/packages/core/src/utils/DisorderedArray.ts @@ -1,8 +1,11 @@ +import { defaultCloneMode } from "../clone/CloneManager"; +import { CloneMode } from "../clone/enums/CloneMode"; import { Utils } from "../Utils"; /** * High-performance unordered array, delete uses exchange method to improve performance, internal capacity only increases. */ +@defaultCloneMode(CloneMode.Ignore) export class DisorderedArray { /** The length of the array. */ length = 0; diff --git a/packages/core/src/utils/SafeLoopArray.ts b/packages/core/src/utils/SafeLoopArray.ts index a5dbc7f7bf..2c801c8277 100644 --- a/packages/core/src/utils/SafeLoopArray.ts +++ b/packages/core/src/utils/SafeLoopArray.ts @@ -1,3 +1,7 @@ +import { defaultCloneMode } from "../clone/CloneManager"; +import { CloneMode } from "../clone/enums/CloneMode"; + +@defaultCloneMode(CloneMode.Ignore) export class SafeLoopArray { private _array: T[] = []; private _loopArray: T[] = []; diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index c37bc0bad6..2c52249f00 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -938,6 +938,29 @@ describe("Clone remap", async () => { }); }); + 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(); + }); + }); + describe("Null-prototype containers", () => { it("Object.create(null) fields deep-clone as data containers, not shared references", () => { const rootEntity = scene.createRootEntity("root"); From a3510d425fa774b54cb9c34d22643117e62bfc65 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 17:02:12 +0800 Subject: [PATCH 033/109] refactor(clone): drop @ignoreClone on runtime-container typed fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 field decorators removed — the type-level Ignore registration on UpdateFlagManager / UpdateFlag / DisorderedArray / SafeLoopArray already covers them. Co-Authored-By: Claude Fable 5 --- packages/core/src/Camera.ts | 4 ---- packages/core/src/Signal.ts | 5 ++--- packages/core/src/animation/Animator.ts | 1 - packages/core/src/mesh/Skin.ts | 1 - packages/core/src/particle/modules/ParticleCompositeCurve.ts | 1 - packages/core/src/particle/modules/ParticleCurve.ts | 2 -- packages/core/src/particle/modules/shape/BaseShape.ts | 2 -- packages/core/src/physics/Collider.ts | 1 - packages/ui/src/component/UICanvas.ts | 2 -- packages/ui/src/component/UIGroup.ts | 1 - 10 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/core/src/Camera.ts b/packages/core/src/Camera.ts index 89579e376e..853ff61ad8 100644 --- a/packages/core/src/Camera.ts +++ b/packages/core/src/Camera.ts @@ -146,13 +146,9 @@ 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; private _shaderData: ShaderData = new ShaderData(ShaderDataGroup.Camera); @ignoreClone diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index 98a14b1778..cb130fc585 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -9,7 +9,6 @@ import { SafeLoopArray } from "./utils/SafeLoopArray"; */ @defaultCloneMode(CloneMode.Deep) export class Signal { - @ignoreClone private _listeners: SafeLoopArray> = new SafeLoopArray>(); /** @@ -129,7 +128,7 @@ export class Signal { * Clone listeners to target signal, remapping entity/component references through the * cloned subtree's identity map (references outside the subtree are kept as-is). */ - _cloneTo(target: Signal, cloneMap: Map): 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]; @@ -144,7 +143,7 @@ export class Signal { } } - private _cloneArguments(args: any[], cloneMap: Map): any[] { + private _cloneArguments(args: any[], cloneMap: Map): any[] { if (!args || args.length === 0) return []; const len = args.length; const clonedArgs = new Array(len); diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 1e837bde91..65595ce473 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -44,7 +44,6 @@ export class Animator extends Component { _onUpdateIndex = -1; protected _animatorController: AnimatorController; - @ignoreClone protected _controllerUpdateFlag: BoolUpdateFlag; @ignoreClone protected _updateMark = 0; diff --git a/packages/core/src/mesh/Skin.ts b/packages/core/src/mesh/Skin.ts index 6ca6957e8a..b48ce776ed 100644 --- a/packages/core/src/mesh/Skin.ts +++ b/packages/core/src/mesh/Skin.ts @@ -18,7 +18,6 @@ export class Skin extends EngineObject { /** @internal */ _skinMatrices: Float32Array; /** @internal */ - @ignoreClone _updatedManager = new UpdateFlagManager(); private _rootBone: Entity; diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 5550459c49..919e8c91ec 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -10,7 +10,6 @@ import { CurveKey, ParticleCurve } from "./ParticleCurve"; */ @defaultCloneMode(CloneMode.Deep) export class ParticleCompositeCurve { - @ignoreClone private _updateManager = new UpdateFlagManager(); private _mode = ParticleCurveMode.Constant; private _constantMin = 0; diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index 0bd77a51fd..08e1d666f5 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -7,7 +7,6 @@ import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; */ @defaultCloneMode(CloneMode.Deep) export class ParticleCurve { - @ignoreClone private _updateManager = new UpdateFlagManager(); private _keys = new Array(); @ignoreClone @@ -198,7 +197,6 @@ export class ParticleCurve { */ @defaultCloneMode(CloneMode.Deep) export class CurveKey { - @ignoreClone private _updateManager = new UpdateFlagManager(); private _time: number; private _value: number; diff --git a/packages/core/src/particle/modules/shape/BaseShape.ts b/packages/core/src/particle/modules/shape/BaseShape.ts index 4ba7f16e30..029bbed8f1 100644 --- a/packages/core/src/particle/modules/shape/BaseShape.ts +++ b/packages/core/src/particle/modules/shape/BaseShape.ts @@ -20,8 +20,6 @@ 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; diff --git a/packages/core/src/physics/Collider.ts b/packages/core/src/physics/Collider.ts index ae4de7f1a1..a44b0d3bc6 100644 --- a/packages/core/src/physics/Collider.ts +++ b/packages/core/src/physics/Collider.ts @@ -22,7 +22,6 @@ export class Collider extends Component implements ICustomClone { /** @internal */ @ignoreClone _nativeCollider: ICollider; - @ignoreClone protected _updateFlag: BoolUpdateFlag; protected _shapes: ColliderShape[] = []; protected _collisionLayerIndex: number = 0; diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index c5b6ec6413..22126d06d3 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -75,7 +75,6 @@ export class UICanvas extends Component implements IElement { @ignoreClone _realRenderMode: number = CanvasRealRenderMode.None; /** @internal */ - @ignoreClone _disorderedElements: DisorderedArray = new DisorderedArray(); @ignoreClone @@ -91,7 +90,6 @@ export class UICanvas extends Component implements IElement { 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 a5a7504304..99b59f3d12 100644 --- a/packages/ui/src/component/UIGroup.ts +++ b/packages/ui/src/component/UIGroup.ts @@ -15,7 +15,6 @@ export class UIGroup extends Component implements IGroupAble { /** @internal */ _rootCanvas: UICanvas; /** @internal */ - @ignoreClone _disorderedElements: DisorderedArray = new DisorderedArray(); /** @internal */ From 887bfcc63f99cfa78672062272cb7b4b5a9de28e Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 17:17:45 +0800 Subject: [PATCH 034/109] docs(clone): define Deep as structure-fresh, member-semantic cloning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloneMode.Deep claimed "an independent copy" — untrue: assets inside stay shared, entity refs remap. Pin the accurate definition on the enum and on _deepClone. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 4 ++-- packages/core/src/clone/enums/CloneMode.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 72fb38e6e5..73b39a37b0 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -207,8 +207,8 @@ export class CloneManager { } /** - * Deep-clone one object graph. Cycles / shared sub-graphs dedup through the identity map, - * which also remaps Entity/Component references nested anywhere in the graph. + * Clone one object graph: the structure is fresh, while every member re-enters the gate and + * follows its own semantics. Cycles / shared sub-graphs dedup through the identity map. */ private static _deepClone(value: any, reuse: any, cloneMap: Map): any { const existing = cloneMap.get(value); diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index 0c12b6b103..8910aac8ef 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -8,6 +8,9 @@ export enum CloneMode { Assignment, /** Remap an Entity / Component reference to its clone within the cloned subtree. */ Remap, - /** Recursively deep clone the value, producing an independent copy. */ + /** + * Recursively clone the graph structure (fresh containers/instances); each member follows its + * own clone semantics — assets stay shared, entity refs remap, runtime state is ignored. + */ Deep } From 83e8328355bb941d1c63de72364c1f00e3c0cf47 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 17:30:10 +0800 Subject: [PATCH 035/109] docs(clone): correct stale or vacuous comments in gate and cloner - deepClone decorator: state the engine-bound fallback instead of an unconditional override - deepCloneObject: fields go through the gate with all decorators, not force-deep - _cloneValue: mode comes from decorator/container/type, not type alone - ICustomClone: drop name-restating filler; document copyFrom as the value-type marker - cloneComponent: mention the trailing _cloneTo hook Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 16 +++++++++------- packages/core/src/clone/ComponentCloner.ts | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 73b39a37b0..0ae5e25482 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -21,8 +21,9 @@ import { ICustomClone } from "./ComponentCloner"; import { CloneMode } from "./enums/CloneMode"; /** - * Property decorator — deep clone this field, overriding the value type's default clone mode. - * Field-level decorators have the highest priority. + * Property decorator — deep clone this field, overriding the value type's default clone mode + * (field-level decorators have the highest priority). Engine-bound values (entities / assets) + * cannot be deep cloned — they fall back to remap / share with a warning. */ export function deepClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); @@ -44,7 +45,7 @@ export function ignoreClone(target: object, propertyKey: string): void { /** * Class decorator — the default clone mode for instances of the decorated type - * (unregistered types fall back to Assignment). + * (unregistered non-container types fall back to Assignment). * @param mode - The clone mode applied to instances of the decorated type */ export function defaultCloneMode(mode: CloneMode) { @@ -95,7 +96,8 @@ export class CloneManager { } /** - * Deep clone all enumerable fields of source into target, respecting `@ignoreClone`. + * Clone all enumerable fields of source into target; each field goes through the clone gate, + * honoring field-level decorators. */ static deepCloneObject(source: object, target: object, cloneMap: Map): void { const ctor = (<{ constructor?: Function }>source).constructor; @@ -123,7 +125,7 @@ export class CloneManager { /** * @internal - * Clone gate — decides how to clone one value based on its type. + * Clone gate — resolves the effective clone mode for one value and executes it. */ static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { // Explicit decorator shares the function; default keeps the clone's own rebound binding. @@ -214,7 +216,7 @@ export class CloneManager { const existing = cloneMap.get(value); if (existing) return existing; - // Value type (Vector3, Color, Matrix, ...) — copy in place. + // Value type (Vector3, Color, Matrix, ...) — copyFrom, reusing the preset when compatible. if ((value).copyFrom) { const dst = reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); @@ -273,7 +275,7 @@ export class CloneManager { } // Object — reuse or construct (null-prototype objects have no ctor: rebuild as such), - // then populate all fields (opt-out) + finalize. + // then populate all fields (opt-out) and run its `_cloneTo` hook. const ctor = object>value.constructor; const dst = reuse && reuse !== value && reuse.constructor === ctor ? reuse : ctor ? new ctor() : Object.create(null); diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index b157a5b49c..8779b4e7d0 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -3,7 +3,7 @@ import { CloneManager } from "./CloneManager"; import { CloneMode } from "./enums/CloneMode"; /** - * Custom clone interface. + * Clone protocol read by the clone system; every member is optional. */ export interface ICustomClone { /** @@ -18,13 +18,14 @@ export interface ICustomClone { _cloneTo?(target: ICustomClone, cloneMap?: Map): void; /** * @internal + * Value-type marker — `_deepClone` copies via this instead of walking fields. */ copyFrom?(source: ICustomClone): void; } export class ComponentCloner { /** - * Clone component (opt-out: all fields cloned except @ignoreClone). + * 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) From bdcc63533c4cc4758bb857c5306e5e5413c5b493 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 17:49:32 +0800 Subject: [PATCH 036/109] refactor(ui): centralize transition state ref counting in the base class State-value counting was scattered across three sites in two classes: setter pairing in Transition._onStateValueDirty, release hoisted into Transition.destroy, but the clone acquisition stranded in SpriteTransition._cloneTo. Counting states via instanceof ReferResource is the base class's pre-existing design, so move the clone +1 there too: one private helper carries all four slots, _cloneTo/destroy pair through it, and SpriteTransition drops its ref-count code entirely. Co-Authored-By: Claude Fable 5 --- .../transition/SpriteTransition.ts | 16 --------- .../interactive/transition/Transition.ts | 33 +++++++++++++------ 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/component/interactive/transition/SpriteTransition.ts b/packages/ui/src/component/interactive/transition/SpriteTransition.ts index 9e4c344e2b..f90441236d 100644 --- a/packages/ui/src/component/interactive/transition/SpriteTransition.ts +++ b/packages/ui/src/component/interactive/transition/SpriteTransition.ts @@ -6,22 +6,6 @@ import { Transition } from "./Transition"; * Sprite transition. */ export class SpriteTransition extends Transition { - /** - * @internal - * The clone gate shares nested state sprites without counting; pair the acquisition here - * with the release in `Transition.destroy` (class-local ownership). - */ - _cloneTo(target: SpriteTransition): void { - // @ts-ignore - target._normal?._addReferCount(1); - // @ts-ignore - target._hover?._addReferCount(1); - // @ts-ignore - target._pressed?._addReferCount(1); - // @ts-ignore - target._disabled?._addReferCount(1); - } - 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 0dcdacd83c..47639d736d 100644 --- a/packages/ui/src/component/interactive/transition/Transition.ts +++ b/packages/ui/src/component/interactive/transition/Transition.ts @@ -127,21 +127,22 @@ export abstract class Transition< destroy(): void { this._interactive?.removeTransition(this); - // Release the ref-counted state values (paired with the setter's +1 and, for clones, - // `_cloneTo`'s +1) — implemented here so every subclass with ReferResource states is covered. - const releaseState = (state: T): void => { - // @ts-ignore - state instanceof ReferResource && state._addReferCount(-1); - }; - releaseState(this._normal); - releaseState(this._pressed); - releaseState(this._hover); - releaseState(this._disabled); + // Release the state values (paired with the setter's +1 and, for clones, `_cloneTo`'s +1). + this._addStateValuesReferCount(-1); this._normal = this._pressed = this._hover = this._disabled = null; this._initialValue = this._currentValue = this._finalValue = null; this._target = null; } + /** + * @internal + * The clone gate shares the state values without counting (nested level); the clone owns + * one reference per ref-counted state — released in `destroy`. + */ + _cloneTo(target: Transition): void { + target._addStateValuesReferCount(1); + } + /** * @internal */ @@ -190,6 +191,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: From 81c7991e4f24a53c9e2544495bceac7cc8ca9684 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 18:09:40 +0800 Subject: [PATCH 037/109] test(math): cover Ray clone and copyFrom Codecov flagged the new Ray value-type methods as untested; the math completeness guard only checks registration, not behavior. Co-Authored-By: Claude Fable 5 --- tests/src/math/Ray.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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); + }); }); From e696681b6f51a71860bfc5d87f0f7976f41c4844 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 20:33:26 +0800 Subject: [PATCH 038/109] fix(clone): release an owned counted preset displaced by an uncounted value _acquireSlotOwnership returned early when the incoming value was not a counted resource, so a constructor-preset counted resource displaced by null/primitive/unregistered data kept its owned reference forever. Release the preset unconditionally; keep the +1 guarded. Flagged by CodeRabbit on the gate contract; covered by a regression test. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 10 ++++--- tests/src/core/CloneUtils.test.ts | 36 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 0ae5e25482..7181ad1397 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -167,11 +167,11 @@ export class CloneManager { /** * @internal - * A component top-level slot that shared a counted resource owns one reference: - * +1 on the shared value, -1 on a replaced owned preset; destroy releases it (class contract). + * A component top-level slot owns its counted content: a shared counted value gains one + * reference, and a replaced owned counted preset releases one — even when the incoming + * value is uncounted. Destroy releases the slot (class contract). */ static _acquireSlotOwnership(value: any, preset: any): void { - if (!CloneManager._isCountedResource(value)) return; if (CloneManager._isCountedResource(preset)) { const presetRefCount = (<{ refCount?: number }>preset).refCount; presetRefCount !== undefined && @@ -182,7 +182,9 @@ export class CloneManager { ); (preset)._addReferCount(-1); } - (value)._addReferCount(1); + if (CloneManager._isCountedResource(value)) { + (value)._addReferCount(1); + } } /** diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index 2c52249f00..e02a1c5475 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -172,6 +172,21 @@ class ResourceRefScript extends Script { } } +/** 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 misusing @deepClone on an Entity ref (must fall back to remap, never construct) */ class DeepEntityRefScript extends Script { @deepClone @@ -1101,6 +1116,27 @@ describe("Clone remap", async () => { 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(); + }); }); describe("Single entity with multiple same-type components", () => { From b0064abeaa9a65f4ff0f4a50afd2a5bc391b91e6 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 20:45:18 +0800 Subject: [PATCH 039/109] fix(clone): register typed-array clones in the identity map and harden counted detection - typed array / DataView deep clones now register in the identity map, preserving aliasing topology like every other container branch - _isCountedResource additionally requires the counting API, so a user type registered Assignment without _addReferCount shares safely instead of crashing on a missing method - Signal reuses a shared CloneManager._isRemapType predicate instead of a hand-rolled duplicate; drop its orphaned ignoreClone import - CustomDataModule._cloneTo threads the subtree identity map instead of a private one Addresses the confirmed items from the second-round review. Co-Authored-By: Claude Fable 5 --- packages/core/src/Signal.ts | 5 +- packages/core/src/clone/CloneManager.ts | 41 +++++++++++----- .../src/particle/modules/CustomDataModule.ts | 8 ++-- tests/src/core/CloneUtils.test.ts | 48 +++++++++++++++++++ 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index cb130fc585..3cdfc96bdc 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,5 +1,5 @@ import { Component } from "./Component"; -import { defaultCloneMode, ignoreClone } from "./clone/CloneManager"; +import { CloneManager, defaultCloneMode } from "./clone/CloneManager"; import { CloneMode } from "./clone/enums/CloneMode"; import { SafeLoopArray } from "./utils/SafeLoopArray"; @@ -151,8 +151,7 @@ export class Signal { const arg = args[i]; // Only Entity/Component references (Remap types) are remapped; other objects are shared // deterministically (looking them up in the identity map would depend on field-walk order). - clonedArgs[i] = - arg instanceof Object && (arg)._defaultCloneMode === CloneMode.Remap ? (cloneMap.get(arg) ?? arg) : arg; + clonedArgs[i] = CloneManager._isRemapType(arg) ? (cloneMap.get(arg) ?? arg) : arg; } return clonedArgs; } diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 7181ad1397..c44fbec3b9 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -188,11 +188,24 @@ export class CloneManager { } /** - * Only explicitly registered Assignment types (ReferResource family) participate in - * ref counting — excludes duck-typed counters like Shader. + * @internal + * Whether the value is an engine-bound Remap type (Entity / Component). + */ + static _isRemapType(value: any): boolean { + return value instanceof Object && (value)._defaultCloneMode === CloneMode.Remap; + } + + /** + * Counted = registered Assignment (the ReferResource family) AND implementing the counting + * API — a user type registered Assignment without `_addReferCount` is a plain share; + * duck-typed counters that never registered (Shader) stay excluded. */ private static _isCountedResource(value: any): boolean { - return value instanceof Object && (value)._defaultCloneMode === CloneMode.Assignment; + return ( + value instanceof Object && + (value)._defaultCloneMode === CloneMode.Assignment && + typeof (value)._addReferCount === "function" + ); } /** @@ -230,22 +243,28 @@ export class CloneManager { // ArrayBuffer views — byte copy (covers every view `_isContainer` routes here, incl. DataView). if (ArrayBuffer.isView(value)) { + let dst: ArrayBufferView; if (value instanceof DataView) { const src = value; if (reuse instanceof DataView && reuse.byteLength === src.byteLength) { new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( new Uint8Array(src.buffer, src.byteOffset, src.byteLength) ); - return reuse; + dst = reuse; + } else { + dst = new DataView(src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength)); + } + } else { + const src = value; + if (reuse && reuse.constructor === src.constructor && (reuse).length === src.length) { + (reuse).set(src); + dst = reuse; + } else { + dst = src.slice(); } - return new DataView(src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength)); - } - const src = value; - if (reuse && reuse.constructor === src.constructor && (reuse).length === src.length) { - (reuse).set(src); - return reuse; } - return src.slice(); + cloneMap.set(value, dst); + return dst; } // Array — fresh instance, each member through the gate. diff --git a/packages/core/src/particle/modules/CustomDataModule.ts b/packages/core/src/particle/modules/CustomDataModule.ts index 4ad2fa6fc9..634faccde5 100644 --- a/packages/core/src/particle/modules/CustomDataModule.ts +++ b/packages/core/src/particle/modules/CustomDataModule.ts @@ -189,17 +189,15 @@ export class CustomDataModule extends ParticleGeneratorModule { /** * @internal */ - _cloneTo(target: CustomDataModule): void { - // Shared across both loops so cross-entry sub-object references stay shared in the clone. - const deepInstanceMap = new Map(); + _cloneTo(target: CustomDataModule, cloneMap: Map): void { for (const [name, curve] of this._curves) { const clonedCurve = new ParticleCompositeCurve(0); - CloneManager.deepCloneObject(curve, clonedCurve, deepInstanceMap); + CloneManager.deepCloneObject(curve, clonedCurve, cloneMap); target.addCurve(name, clonedCurve); } for (const [name, gradient] of this._gradients) { const clonedGradient = new ParticleCompositeGradient(new Color()); - CloneManager.deepCloneObject(gradient, clonedGradient, deepInstanceMap); + CloneManager.deepCloneObject(gradient, clonedGradient, cloneMap); target.addGradient(name, clonedGradient); } } diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index e02a1c5475..b591b664a7 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -7,6 +7,7 @@ import { Texture2D, assignmentClone, deepClone, + defaultCloneMode, ignoreClone } from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; @@ -187,6 +188,23 @@ class PresetTextureScript extends Script { } } +/** Script with two fields aliasing one typed array (identity must survive the clone). */ +class AliasedBinaryScript extends Script { + a: Float32Array; + b: Float32Array; +} + +/** User value type registered Assignment without any counting API — must share, never count. */ +@defaultCloneMode(CloneMode.Assignment) +class SharedConfig { + value = 1; +} + +/** Script holding a user Assignment-registered object. */ +class SharedConfigScript extends Script { + config: SharedConfig = null; +} + /** Script misusing @deepClone on an Entity ref (must fall back to remap, never construct) */ class DeepEntityRefScript extends Script { @deepClone @@ -1088,6 +1106,24 @@ describe("Clone remap", async () => { 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(); + }); }); describe("Script-held ReferResource", () => { @@ -1137,6 +1173,18 @@ describe("Clone remap", async () => { 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", () => { From d095812179ae31f9e839c651eb39ef90d0c36d03 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 22:10:50 +0800 Subject: [PATCH 040/109] fix(clone): route copyFrom dispatch after containers and close review-confirmed defects Findings from an exhaustive multi-agent review, each adversarially verified: - _deepClone: the copyFrom value-type branch ran before the container branches and matched any truthy member, so a plain object carrying a copyFrom data key crashed entity.clone(); it now dispatches after the container branches and only for class instances with a callable copyFrom - _deepClone: the typed-array/DataView reuse path lacked the reuse !== value guard, so a preset aliasing the source value (shared static default tables) silently returned the source buffer - physics: cloned MeshColliderShape was attached twice to the native actor (mesh-setter rebuild plus Collider._syncNative); attachment state is now tracked on ColliderShape and _addNativeShape skips re-attach - SkinnedMeshRenderer: the renderer-private joint texture copied by ShaderData's clone kept the source's one-shot destroy() refused and leaked the GC-ignored GPU texture; the clone now drops the entry - tests: cover all four fixes plus the unowned-preset diagnostic branch Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 31 +++-- packages/core/src/mesh/SkinnedMeshRenderer.ts | 7 ++ packages/core/src/physics/Collider.ts | 8 +- .../core/src/physics/shape/ColliderShape.ts | 3 + .../src/physics/shape/MeshColliderShape.ts | 2 - tests/src/core/CloneTextureRefCount.test.ts | 26 ++++ tests/src/core/CloneUtils.test.ts | 119 +++++++++++++++++- .../core/physics/MeshColliderShape.test.ts | 2 + 8 files changed, 180 insertions(+), 18 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index c44fbec3b9..fd87e87efc 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -231,22 +231,12 @@ export class CloneManager { const existing = cloneMap.get(value); if (existing) return existing; - // Value type (Vector3, Color, Matrix, ...) — copyFrom, reusing the preset when compatible. - if ((value).copyFrom) { - const dst = - reuse && reuse !== value && reuse.constructor === value.constructor ? reuse : new (value.constructor)(); - cloneMap.set(value, dst); - (dst).copyFrom(value); - (value)._cloneTo?.(dst, cloneMap); - return dst; - } - // ArrayBuffer views — byte copy (covers every view `_isContainer` routes here, incl. DataView). if (ArrayBuffer.isView(value)) { let dst: ArrayBufferView; if (value instanceof DataView) { const src = value; - if (reuse instanceof DataView && reuse.byteLength === src.byteLength) { + if (reuse instanceof DataView && reuse !== value && reuse.byteLength === src.byteLength) { new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( new Uint8Array(src.buffer, src.byteOffset, src.byteLength) ); @@ -256,7 +246,12 @@ export class CloneManager { } } else { const src = value; - if (reuse && reuse.constructor === src.constructor && (reuse).length === src.length) { + if ( + reuse && + reuse !== value && + reuse.constructor === src.constructor && + (reuse).length === src.length + ) { (reuse).set(src); dst = reuse; } else { @@ -295,9 +290,19 @@ export class CloneManager { return dst; } + // Value type (Vector3, Color, Matrix, ...) — a class instance carrying a callable copyFrom. + // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in the data. + const ctor = value.constructor; + if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { + const dst = (reuse && reuse !== value && reuse.constructor === ctor ? reuse : new ctor()); + cloneMap.set(value, dst); + dst.copyFrom(value); + (value)._cloneTo?.(dst, cloneMap); + return dst; + } + // Object — reuse or construct (null-prototype objects have no ctor: rebuild as such), // then populate all fields (opt-out) and run its `_cloneTo` hook. - const ctor = object>value.constructor; const dst = reuse && reuse !== value && reuse.constructor === ctor ? reuse : ctor ? new ctor() : Object.create(null); cloneMap.set(value, dst); diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts index 9dc04e9538..78c463f641 100644 --- a/packages/core/src/mesh/SkinnedMeshRenderer.ts +++ b/packages/core/src/mesh/SkinnedMeshRenderer.ts @@ -144,6 +144,13 @@ export class SkinnedMeshRenderer extends MeshRenderer { override _cloneTo(target: SkinnedMeshRenderer): void { super._cloneTo(target); + // The joint texture is renderer-private (isGCIgnored): drop the entry ShaderData's clone + // copied, or the source's destroy() is refused (refCount > 0) and the GPU texture leaks. + // The clone's first update rebuilds its own texture (_jointDataCreateCache is fresh). + if (this._jointTexture) { + target.shaderData.setTexture(SkinnedMeshRenderer._jointSamplerProperty, null); + } + if (this.skin) { target._applySkin(null, target.skin); } diff --git a/packages/core/src/physics/Collider.ts b/packages/core/src/physics/Collider.ts index a44b0d3bc6..689f283c2a 100644 --- a/packages/core/src/physics/Collider.ts +++ b/packages/core/src/physics/Collider.ts @@ -181,16 +181,20 @@ 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._isShapeAttached = true; } } protected _removeNativeShape(shape: ColliderShape): void { shape._collider = null; - if (shape._nativeShape) { + if (shape._nativeShape && shape._isShapeAttached) { this._nativeCollider.removeShape(shape._nativeShape); + shape._isShapeAttached = false; } } diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 0e8cc7f202..9a4be8d0aa 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -20,6 +20,9 @@ export abstract class ColliderShape implements ICustomClone { /** @internal */ @ignoreClone _nativeShape: IColliderShape; + /** @internal Whether the native shape is currently attached to a collider's native actor. */ + @ignoreClone + _isShapeAttached: boolean = false; @ignoreClone protected _id: number; diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts index f434ead5be..19f7ed9b57 100644 --- a/packages/core/src/physics/shape/MeshColliderShape.ts +++ b/packages/core/src/physics/shape/MeshColliderShape.ts @@ -19,8 +19,6 @@ export class MeshColliderShape extends ColliderShape { @ignoreClone private _indices: Uint8Array | Uint16Array | Uint32Array | null = null; private _cookingFlags = MeshColliderShapeCookingFlag.Cleaning | MeshColliderShapeCookingFlag.VertexWelding; - @ignoreClone - private _isShapeAttached = false; /** * Cooking flags for this mesh collider shape. diff --git a/tests/src/core/CloneTextureRefCount.test.ts b/tests/src/core/CloneTextureRefCount.test.ts index dfc9e29976..c0c6024729 100644 --- a/tests/src/core/CloneTextureRefCount.test.ts +++ b/tests/src/core/CloneTextureRefCount.test.ts @@ -6,6 +6,7 @@ import { Font, MeshRenderer, RenderTarget, + SkinnedMeshRenderer, TextRenderer, Texture2D } from "@galacean/engine-core"; @@ -22,6 +23,31 @@ describe("Clone resource refCount", async function () { 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 now succeeds. + 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"); diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts index b591b664a7..9a20c667e0 100644 --- a/tests/src/core/CloneUtils.test.ts +++ b/tests/src/core/CloneUtils.test.ts @@ -1,6 +1,7 @@ import { CloneMode, Entity, + Logger, MeshRenderer, Script, Signal, @@ -12,7 +13,7 @@ import { } from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; class TestScript extends Script { targetEntity: Entity; @@ -205,6 +206,33 @@ 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; + } +} + +/** 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 misusing @deepClone on an Entity ref (must fall back to remap, never construct) */ class DeepEntityRefScript extends Script { @deepClone @@ -1124,6 +1152,76 @@ describe("Clone remap", async () => { 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(); + }); + }); + + 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("Script-held ReferResource", () => { @@ -1174,6 +1272,25 @@ describe("Clone remap", async () => { 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 user type registered Assignment without counting API shares safely", () => { const rootEntity = scene.createRootEntity("root"); const parent = rootEntity.createChild("parent"); diff --git a/tests/src/core/physics/MeshColliderShape.test.ts b/tests/src/core/physics/MeshColliderShape.test.ts index 48b06b3990..860efd9708 100644 --- a/tests/src/core/physics/MeshColliderShape.test.ts +++ b/tests/src/core/physics/MeshColliderShape.test.ts @@ -782,6 +782,8 @@ describe("MeshColliderShape PhysX", () => { 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); From 6d0c7fececd2fd08ab3d8206d2e88ed34db654d2 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 22:18:37 +0800 Subject: [PATCH 041/109] test(clone): cover the rewritten Skin, PostProcess and TrailRenderer clone paths Review flagged these three subsystems as mechanism-rewritten with zero clone coverage: skin rootBone/bones remap with independent bind matrices, post-process effect/parameter independence with shared texture refs, and trail curve/gradient/textureScale independence. Co-Authored-By: Claude Fable 5 --- tests/src/core/SkinnedMeshRenderer.test.ts | 53 ++++++++++++++++++ tests/src/core/Trail.test.ts | 56 +++++++++++++++++++ .../src/core/postProcess/PostProcess.test.ts | 51 +++++++++++++++++ 3 files changed, 160 insertions(+) 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/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; From 78ec1994280225cacbd42e0a6c3babe11b54e9aa Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 3 Jul 2026 22:18:40 +0800 Subject: [PATCH 042/109] docs(clone): align clone guides with the type-driven mechanism Remove the deleted shallowClone decorator from tables, samples and API links (with a migration callout), replace the old 'undecorated fields are shared' default with the type-driven resolution table, and document entity remapping and the asset ref-count contract. Co-Authored-By: Claude Fable 5 --- docs/en/core/clone.mdx | 76 +++++++++++++++++++++++++++------- docs/en/how-to-contribute.mdx | 4 +- docs/zh/core/clone.mdx | 77 +++++++++++++++++++++++++++-------- docs/zh/how-to-contribute.mdx | 4 +- 4 files changed, 126 insertions(+), 35 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 2453b0531d..0fe050ce1f 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -14,7 +14,7 @@ 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{ @@ -40,19 +40,62 @@ 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 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. | +| Other objects | Share the reference (assignment). | + +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; + +// 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 take effect at any depth of the cloned object graph. 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 during cloning. The field gets a fresh, independent structure, and each inner member is cloned again according to its own semantics. Engine-bound values cannot be deep cloned: `Entity` / `Component` references fall back to remapping with a warning, and assets fall back to sharing with a warning. | + + +`@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 `shallowClone` and `deepClone` are more complex, we add additional print output to the fields `c` and `d` for further explanation. +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. ```typescript // define a custom script class CustomScript extends Script{ @@ -65,7 +108,7 @@ class CustomScript extends Script{ b:number = 1; /** class type.*/ - @shallowClone + @assignmentClone c:Vector3[] = [new Vector3(0,0,0)]; /** class type.*/ @@ -84,20 +127,23 @@ 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). -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. + - Field decorators have the highest priority and also take effect on the fields of nested classes at any depth of the cloned object graph. + - `deepClone` recursively clones the structure, while each inner member is still cloned according to its own semantics: assets stay shared, entity references are remapped, and nested field decorators still apply. + - `deepClone` cannot deep clone engine-bound objects: `Entity` / `Component` references fall back to remapping and assets fall back to sharing, both with a warning. If you need a real copy of an asset, use the asset's own clone API. - 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. The class holding the field is responsible for releasing that reference when it is destroyed — built-in components already handle this; for custom scripts, release the resources you hold in `onDestroy`. 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..51d4abdcb5 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -9,13 +9,13 @@ 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{ @@ -41,19 +41,62 @@ 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`、普通对象) | 深克隆 —— 创建全新的容器,每个成员再按各自的类型语义继续克隆。 | +| 运行时容器(`UpdateFlagManager` 等内部瞬态状态) | 忽略 —— 克隆体保留自身构造时的值。 | +| 其他对象 | 共享引用(赋值)。 | + +因此在不加装饰器的情况下,普通数据对象和数组默认会得到独立的深拷贝,资产依旧共享,实体引用则自动重映射: +```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; + +// 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` 引用会回退为重映射并告警,资产会回退为共享并告警。| -我们将上面的案例稍加修改,分别对 `CustomScript` 中的四个字段添加了不同的“克隆装饰器“。由于 `shallowClone` 和 `deepCone`  较复杂,我们对字段 `c` 和 `d` 增加了额外的打印输出进行进一步讲解。 + +`@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 `@deepClone`,希望共享就使用 `@assignmentClone`。 + + +我们将上面的案例稍加修改,分别对 `CustomScript` 中的四个字段添加了不同的“克隆装饰器“。由于数组默认就会被深克隆,我们对字段 `c` 使用 `assignmentClone` 来强制共享,对字段 `d` 使用 `deepClone` 显式声明默认行为,并增加了额外的打印输出进行进一步讲解。 ```typescript // define a custom script class CustomScript extends Script{ @@ -66,7 +109,7 @@ class CustomScript extends Script{ b:number = 1; /** class type.*/ - @shallowClone + @assignmentClone c:Vector3[] = [new Vector3(0,0,0)]; /** class type.*/ @@ -85,21 +128,23 @@ 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). -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` 为深克隆,会对属性进行深度递归,至于属性的子属性如何克隆,取决于子属性的装饰器。 + - 字段装饰器优先级最高,并且对克隆对象图任意深度上嵌套类的字段同样生效。 + - `deepClone` 会对结构进行深度递归克隆,内部每个成员仍按各自的语义克隆:资产保持共享,实体引用被重映射,嵌套字段上的装饰器依然生效。 + - `deepClone` 无法深克隆引擎绑定的对象:`Entity` / `Component` 引用会回退为重映射,资产会回退为共享,二者都会打印告警。如果需要真正复制一份资产,请使用资产自身的克隆 API。 - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 +## 克隆与资产引用计数 +克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数。持有该字段的类负责在销毁时释放这次引用 —— 内置组件已由引擎处理;自定义脚本请在 `onDestroy` 中释放自己持有的资源。 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)]; } ``` From 9fa3cc21734f41c3a22dc13eb9177bcc373f53ac Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 08:57:08 +0800 Subject: [PATCH 043/109] refactor(clone): settle slot ownership unconditionally and fold duplicate walks Simplifications from a five-lens review, each judged for semantic equivalence and net win: - ComponentCloner no longer guards the settlement call: the cloned===sourceValue reasoning moves into _transferSlotOwnership, which now also releases an owned counted preset displaced by a deep-cloned value (closing the last accounting gap) - _deepClone's object branch delegates its field walk to deepCloneObject; the reuse predicate is hoisted into one 'reusable' - the function-branch nested ternary flattens to a single condition - attach/detach primitives move next to their _isShapeAttached flag on ColliderShape; Collider and MeshColliderShape both delegate Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 35 ++++++++++--------- packages/core/src/clone/ComponentCloner.ts | 4 +-- packages/core/src/physics/Collider.ts | 8 ++--- .../core/src/physics/shape/ColliderShape.ts | 18 ++++++++++ .../src/physics/shape/MeshColliderShape.ts | 10 ------ ...loneUtils.test.ts => CloneManager.test.ts} | 0 6 files changed, 40 insertions(+), 35 deletions(-) rename tests/src/core/{CloneUtils.test.ts => CloneManager.test.ts} (100%) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index fd87e87efc..2d6c0c14fb 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -130,7 +130,7 @@ export class CloneManager { static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { // Explicit decorator shares the function; default keeps the clone's own rebound binding. if (typeof value === "function") { - return fieldMode !== undefined ? value : typeof reuse === "function" ? reuse : value; + return fieldMode === undefined && typeof reuse === "function" ? reuse : value; } // `typeof`, not `instanceof` — null-prototype / cross-realm objects lack local Object.prototype. if (value === null || typeof value !== "object") return value; @@ -167,11 +167,13 @@ export class CloneManager { /** * @internal - * A component top-level slot owns its counted content: a shared counted value gains one - * reference, and a replaced owned counted preset releases one — even when the incoming - * value is uncounted. Destroy releases the slot (class contract). + * Settle a slot's counted ownership after the gate wrote it: an unchanged slot keeps its + * account, a displaced owned counted preset releases one reference, and a slot sharing the + * source's counted value acquires one. Destroy releases the slot (class contract). */ - static _acquireSlotOwnership(value: any, preset: any): void { + static _transferSlotOwnership(cloned: any, sourceValue: any, preset: any): void { + // Slot content unchanged (Ignore kept / value type copied in place / function reused). + if (cloned === preset) return; if (CloneManager._isCountedResource(preset)) { const presetRefCount = (<{ refCount?: number }>preset).refCount; presetRefCount !== undefined && @@ -182,8 +184,10 @@ export class CloneManager { ); (preset)._addReferCount(-1); } - if (CloneManager._isCountedResource(value)) { - (value)._addReferCount(1); + // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path + // returns a registered resource as-is), so it owns one reference. + if (cloned === sourceValue && CloneManager._isCountedResource(cloned)) { + (cloned)._addReferCount(1); } } @@ -290,11 +294,14 @@ export class CloneManager { return dst; } + const ctor = value.constructor; + // Compatible reuse: a distinct instance of the exact same type (null-prototype matches null-prototype). + const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; + // Value type (Vector3, Color, Matrix, ...) — a class instance carrying a callable copyFrom. // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in the data. - const ctor = value.constructor; if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { - const dst = (reuse && reuse !== value && reuse.constructor === ctor ? reuse : new ctor()); + const dst = (reusable ?? new ctor()); cloneMap.set(value, dst); dst.copyFrom(value); (value)._cloneTo?.(dst, cloneMap); @@ -303,15 +310,9 @@ export class CloneManager { // Object — reuse or construct (null-prototype objects have no ctor: rebuild as such), // then populate all fields (opt-out) and run its `_cloneTo` hook. - const dst = - reuse && reuse !== value && reuse.constructor === ctor ? reuse : ctor ? new ctor() : Object.create(null); + const dst = reusable ?? (ctor ? new ctor() : Object.create(null)); cloneMap.set(value, dst); - const fieldModes = ctor ? CloneManager.getFieldModes(ctor) : null; - for (const key in value) { - const fieldMode = fieldModes?.get(key); - if (fieldMode === CloneMode.Ignore) continue; - dst[key] = CloneManager._cloneValue(value[key], dst[key], cloneMap, fieldMode); - } + CloneManager.deepCloneObject(value, dst, cloneMap); (value)._cloneTo?.(dst, cloneMap); return dst; } diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 8779b4e7d0..4624ad681f 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -38,9 +38,7 @@ export class ComponentCloner { const sourceValue = source[k]; const preset = target[k]; const cloned = (target[k] = CloneManager._cloneValue(sourceValue, preset, cloneMap, fieldMode)); - // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path - // returns a registered resource as-is), so it owns one reference. - cloned === sourceValue && CloneManager._acquireSlotOwnership(cloned, preset); + CloneManager._transferSlotOwnership(cloned, sourceValue, preset); } ((source as unknown))._cloneTo?.(target, cloneMap); } diff --git a/packages/core/src/physics/Collider.ts b/packages/core/src/physics/Collider.ts index 689f283c2a..7a6f1b76b8 100644 --- a/packages/core/src/physics/Collider.ts +++ b/packages/core/src/physics/Collider.ts @@ -185,17 +185,15 @@ export class Collider extends Component implements ICustomClone { // 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._isShapeAttached = true; + shape._attachToCollider(); } } protected _removeNativeShape(shape: ColliderShape): void { - shape._collider = null; if (shape._nativeShape && shape._isShapeAttached) { - this._nativeCollider.removeShape(shape._nativeShape); - shape._isShapeAttached = false; + shape._detachFromCollider(); } + shape._collider = null; } private _setCollisionLayer(): void { diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 9a4be8d0aa..0e6637ec6b 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -182,6 +182,24 @@ export abstract class ColliderShape implements ICustomClone { delete Engine._physicalObjectsMap[this._id]; } + /** + * @internal + * Attach the native shape to the owning collider's native actor. + */ + _attachToCollider(): void { + this._collider._nativeCollider.addShape(this._nativeShape); + this._isShapeAttached = true; + } + + /** + * @internal + * Detach the native shape from the owning collider's native actor. + */ + _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 19f7ed9b57..6eff3b7df2 100644 --- a/packages/core/src/physics/shape/MeshColliderShape.ts +++ b/packages/core/src/physics/shape/MeshColliderShape.ts @@ -183,16 +183,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/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneManager.test.ts similarity index 100% rename from tests/src/core/CloneUtils.test.ts rename to tests/src/core/CloneManager.test.ts From 23f0459d6ca6024431860c27dd0edd7958285cd6 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 08:57:14 +0800 Subject: [PATCH 044/109] test(clone): rename the suite after CloneManager and fix stale attributions CloneUtils.test.ts was the repo's last reference to the deleted class. Three CloneTextureRefCount comments still attributed gate-acquired references to hooks this PR removed; also cover displaced-preset release. Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneManager.test.ts | 17 +++++++++++++++++ tests/src/core/CloneTextureRefCount.test.ts | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 9a20c667e0..12be956c5a 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1291,6 +1291,23 @@ describe("Clone remap", async () => { 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"); diff --git a/tests/src/core/CloneTextureRefCount.test.ts b/tests/src/core/CloneTextureRefCount.test.ts index c0c6024729..3fa8df1795 100644 --- a/tests/src/core/CloneTextureRefCount.test.ts +++ b/tests/src/core/CloneTextureRefCount.test.ts @@ -74,7 +74,7 @@ describe("Clone resource refCount", async function () { const clone = entity.clone(); rootEntity.addChild(clone); - // The clone holds exactly one additional reference (acquired in Camera._cloneTo). + // The clone holds exactly one additional reference (acquired by the clone gate on the shared `_renderTarget` slot). expect(rt.refCount).eq(2); clone.destroy(); @@ -92,7 +92,7 @@ describe("Clone resource refCount", async function () { const clone = entity.clone(); rootEntity.addChild(clone); - // The clone holds exactly one additional reference (acquired via the font setter in _cloneTo). + // 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(); @@ -109,7 +109,7 @@ describe("Clone resource refCount", async function () { const clone = entity.clone(); rootEntity.addChild(clone); - // The clone holds exactly one additional reference (acquired in Animator._cloneTo). + // 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(); From 2a919171b46817273292f46de57cf47a6af3ef53 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 09:58:08 +0800 Subject: [PATCH 045/109] perf(clone): iterate Map/Set with for-of to avoid per-clone closure allocation The forEach arrow closures were the only unconditional intermediate allocations on the clone path besides the clone's own output; for-of iterators are erasable by escape analysis, closures are not. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 2d6c0c14fb..07f89637a6 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -276,13 +276,16 @@ export class CloneManager { return dst; } - // Map + // Map — `for-of` instead of `forEach`: no per-clone closure allocation. if (value instanceof Map) { const dst = new Map(); cloneMap.set(value, dst); - value.forEach((v, key) => { - dst.set(CloneManager._cloneValue(key, undefined, cloneMap), CloneManager._cloneValue(v, undefined, cloneMap)); - }); + for (const entry of value) { + dst.set( + CloneManager._cloneValue(entry[0], undefined, cloneMap), + CloneManager._cloneValue(entry[1], undefined, cloneMap) + ); + } return dst; } @@ -290,7 +293,9 @@ export class CloneManager { if (value instanceof Set) { const dst = new Set(); cloneMap.set(value, dst); - value.forEach((v) => dst.add(CloneManager._cloneValue(v, undefined, cloneMap))); + for (const v of value) { + dst.add(CloneManager._cloneValue(v, undefined, cloneMap)); + } return dst; } From c13dd08e5c80457ada90f2d84beb915ca6f94926 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 10:05:34 +0800 Subject: [PATCH 046/109] docs(clone): drop a change-narrating comment on the Map branch Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 07f89637a6..ab676bce0a 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -276,7 +276,7 @@ export class CloneManager { return dst; } - // Map — `for-of` instead of `forEach`: no per-clone closure allocation. + // Map if (value instanceof Map) { const dst = new Map(); cloneMap.set(value, dst); From 6d724c798e621eeb5141c32b31f795b724fa8e0d Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 10:09:17 +0800 Subject: [PATCH 047/109] docs(clone): drop tense-relative wording from a lifecycle test comment Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneTextureRefCount.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/core/CloneTextureRefCount.test.ts b/tests/src/core/CloneTextureRefCount.test.ts index 3fa8df1795..f4eafdd53d 100644 --- a/tests/src/core/CloneTextureRefCount.test.ts +++ b/tests/src/core/CloneTextureRefCount.test.ts @@ -41,7 +41,7 @@ describe("Clone resource refCount", async function () { 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 now succeeds. + // With no stray reference, the source's one-shot destroy() in _onDestroy succeeds (refCount is zero). entity.destroy(); expect(jointTexture.refCount).eq(0); From 828008f9b88f235f011e6f11023c98969650fcad Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 20:18:45 +0800 Subject: [PATCH 048/109] fix(particle): allow bare construction of ParticleCompositeGradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clone gate constructs container elements without arguments and then populates every field, but the constructor dereferenced its first parameter unconditionally — a gradient held in an array/map/plain object crashed entity.clone(). The field defaults already form a valid constant-mode state, so an argument-less call now simply keeps them. Audited every Deep-registered type for the same shape: this was the only bare-construction crash. Covers container-element cloning and the previously untested emission-bursts engine path. Co-Authored-By: Claude Fable 5 --- .../modules/ParticleCompositeGradient.ts | 10 ++++- tests/src/core/CloneManager.test.ts | 43 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index f516cd690e..134dbff26d 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -44,6 +44,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 @@ -70,7 +75,10 @@ export class ParticleCompositeGradient { */ constructor(gradientMin: ParticleGradient, gradientMax: ParticleGradient); - constructor(constantOrGradient: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) { + constructor(constantOrGradient?: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) { + // No argument: the field defaults already form a valid constant-mode state (clone gate + // constructs container elements bare, then populates every field). + if (!constantOrGradient) return; if (constantOrGradient.constructor === Color) { if (constantMaxOrGradientMax) { this.constantMin.copyFrom(constantOrGradient); diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 12be956c5a..ac6deb9619 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1,8 +1,12 @@ import { + Burst, CloneMode, Entity, Logger, MeshRenderer, + ParticleCompositeCurve, + ParticleCompositeGradient, + ParticleRenderer, Script, Signal, Texture2D, @@ -11,7 +15,7 @@ import { defaultCloneMode, ignoreClone } from "@galacean/engine-core"; -import { Vector3 } from "@galacean/engine-math"; +import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { describe, expect, it, vi } from "vitest"; @@ -1224,6 +1228,43 @@ describe("Clone remap", async () => { }); }); + 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("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"); From 16d95d6f38499a8889154172bb908a5ecd1b6943 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 20:51:44 +0800 Subject: [PATCH 049/109] test(clone): enforce the bare-construction contract for Deep-registered types Cocos-style mechanism guard instead of per-class patching: the gate creates preset-less instances with new Type(), so Deep registration implies argument-less constructibility. A guard test enumerates every exported Deep type across core/math/ui and bare-constructs it; the explicit exemption list names the engine-bound structural types the gate only ever clones against a same-type preset, with reasons. The contract is stated on the defaultCloneMode decorator doc. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 2 ++ tests/src/core/CloneManager.test.ts | 47 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index ab676bce0a..24967c38c2 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -46,6 +46,8 @@ export function ignoreClone(target: object, propertyKey: string): void { /** * Class decorator — the default clone mode for instances of the decorated type * (unregistered non-container types fall back to Assignment). + * A type registered `Deep` must construct without arguments: the gate creates preset-less + * instances bare and then populates every field. * @param mode - The clone mode applied to instances of the decorated type */ export function defaultCloneMode(mode: CloneMode) { diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index ac6deb9619..e6b301f730 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -15,6 +15,9 @@ import { defaultCloneMode, 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, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { describe, expect, it, vi } from "vitest"; @@ -1248,6 +1251,50 @@ describe("Clone remap", async () => { 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", + // Engine-bound structural types wired to their host at construction — the gate always + // clones them against the component's same-type constructor preset, never bare. + "ParticleGenerator", + "MainModule", + "VelocityOverLifetimeModule", + "SizeOverLifetimeModule", + "LimitVelocityOverLifetimeModule", + "NoiseModule" + ]); + const failures: string[] = []; + const packages: [string, Record][] = [ + ["core", EngineCore], + ["math", EngineMath], + ["ui", EngineUI] + ]; + for (const [pkg, ns] of packages) { + for (const [name, exported] of Object.entries(ns)) { + if (typeof exported !== "function" || !exported.prototype) continue; + if (exported.prototype._defaultCloneMode !== CloneMode.Deep) continue; + if (exempt.has(name)) continue; + try { + new exported(); + } catch (e) { + failures.push(`${pkg}/${name}: ${(e as Error).message}`); + } + } + } + expect(failures).deep.eq([]); + }); + it("clones the emission bursts array through the engine path", () => { const rootEntity = scene.createRootEntity("root"); const parent = rootEntity.createChild("parent"); From f58d380dcec19ee546fa74f3f56d41612d9524f2 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 21:24:16 +0800 Subject: [PATCH 050/109] feat(clone): name the bare-construction contract when a deep clone cannot construct A user type registered Deep with a required-argument constructor used to surface the constructor's raw TypeError from inside the gate; the failure now states the contract (argument-less construction) and the type name, with the original error attached as the cause. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 20 ++++++++++++++++++-- tests/src/core/CloneManager.test.ts | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 24967c38c2..1b541775f4 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -308,7 +308,7 @@ export class CloneManager { // Value type (Vector3, Color, Matrix, ...) — a class instance carrying a callable copyFrom. // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in the data. if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { - const dst = (reusable ?? new ctor()); + const dst = (reusable ?? CloneManager._bareConstruct(ctor)); cloneMap.set(value, dst); dst.copyFrom(value); (value)._cloneTo?.(dst, cloneMap); @@ -317,12 +317,28 @@ export class CloneManager { // Object — reuse or construct (null-prototype objects have no ctor: rebuild as such), // then populate all fields (opt-out) and run its `_cloneTo` hook. - const dst = reusable ?? (ctor ? new ctor() : Object.create(null)); + const dst = reusable ?? (ctor ? CloneManager._bareConstruct(ctor) : Object.create(null)); cloneMap.set(value, dst); CloneManager.deepCloneObject(value, dst, cloneMap); (value)._cloneTo?.(dst, cloneMap); return dst; } + + /** + * A deep-cloned instance without a compatible preset is constructed bare; name the contract + * when that fails instead of surfacing the constructor's raw error. + */ + private static _bareConstruct(ctor: new () => any): any { + try { + return new ctor(); + } catch (e) { + throw new Error( + `CloneManager: 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}` + ); + } + } } // Math value types are always deep cloned; registered here because math cannot depend on core. diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index e6b301f730..e5e63c7648 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -227,6 +227,16 @@ class UnownedPresetScript extends Script { } } +/** User type registered Deep whose constructor dereferences a required argument (contract violation). */ +@defaultCloneMode(CloneMode.Deep) +class ParamDeepConfig { + target: string; + + constructor(source: { id: string }) { + this.target = source.id; + } +} + /** Script holding plain data whose payload happens to carry a `copyFrom` key. */ class CopyFromDataScript extends Script { config: any = null; @@ -1251,6 +1261,17 @@ describe("Clone remap", async () => { 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("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. From 843ab780edbb17c43507ee5e76ad75c520b3d467 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 21:37:58 +0800 Subject: [PATCH 051/109] test(clone): pin the host-bound-in-container rejection as intended semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exemption note overstated the preset guarantee: a user can pull a host-bound module off a live generator and put it in a clonable container, where the gate bare-constructs and fails. That rejection is the intended behavior (a host-bound structure cannot exist without its host; sharing must be declared with @assignmentClone) — state it honestly in the exemption list and lock it with a test. Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneManager.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index e5e63c7648..5cff8c4f08 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1272,6 +1272,20 @@ describe("Clone remap", async () => { rootEntity.destroy(); }); + it("a host-bound structural instance in a user container fails with the named error", () => { + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const renderer = parent.addComponent(ParticleRenderer); + const script = parent.addComponent(CopyFromDataScript); + script.config = { modules: [renderer.generator.main] }; + + // A host-bound structure cannot exist without its host, so a preset-less deep clone is + // rejected with the contract named — sharing must be declared via @assignmentClone. + 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. @@ -1286,8 +1300,10 @@ describe("Clone remap", async () => { "CapsuleColliderShape", "PlaneColliderShape", "MeshColliderShape", - // Engine-bound structural types wired to their host at construction — the gate always - // clones them against the component's same-type constructor preset, never bare. + // 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", From 9db6c66cedba1631274ae71a77b3926ddf224508 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 21:39:45 +0800 Subject: [PATCH 052/109] test(clone): pin both host-bound-in-container behaviors by component order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous pin asserted rejection unconditionally, but when the host's engine slot clones before the user container the identity map already holds the module and the container reference remaps onto the clone — the better outcome. Cover both orders: remap when the host precedes, named-contract rejection when it does not. Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneManager.test.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 5cff8c4f08..ecbae59a93 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1272,15 +1272,33 @@ describe("Clone remap", async () => { rootEntity.destroy(); }); - it("a host-bound structural instance in a user container fails with the named error", () => { + 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] }; - // A host-bound structure cannot exist without its host, so a preset-less deep clone is - // rejected with the contract named — sharing must be declared via @assignmentClone. + 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(); From f8ad0d758ad4e94ad0e13a8cd15f25e61fa66b49 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 4 Jul 2026 22:24:48 +0800 Subject: [PATCH 053/109] fix(shader): cascade texture-array entries through the ref-count contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTextureArray counts its entries by the host's refCount, but the cascade in _addReferCount only handled single textures and cloneTo sliced arrays without acquiring — texture-array entries fell out of the contract this PR pins for ShaderData. A shared helper now applies the same cascade to Texture and Texture[]; covered by cascade and clone lifecycle tests. Also narrows two over-promising docs (Deep bare-construction scoped to preset-less clones; Assignment keep-alive scoped to top-level slots) and strips trailing whitespace in the clone guides. Co-Authored-By: Claude Fable 5 --- docs/en/core/clone.mdx | 4 +- docs/zh/core/clone.mdx | 4 +- packages/core/src/clone/CloneManager.ts | 5 ++- packages/core/src/clone/enums/CloneMode.ts | 2 +- packages/core/src/shader/ShaderData.ts | 26 +++++++++--- tests/src/core/ShaderDataRefCount.test.ts | 47 ++++++++++++++++++++++ 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 0fe050ce1f..c3f4f0f999 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -61,10 +61,10 @@ So without any decorator, plain data objects and arrays get independent deep cop class CustomScript extends Script{ /** Entity reference.*/ target: Entity; - + /** Plain data object.*/ config = { speed: 1, offsets: [0, 1, 2] }; - + /** Asset.*/ texture: Texture2D; } diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index 51d4abdcb5..e713ee8cbe 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -62,10 +62,10 @@ console.log(cloneScript.c); // output is (1,1,1),且是一份独立拷贝 — class CustomScript extends Script{ /** Entity reference.*/ target: Entity; - + /** Plain data object.*/ config = { speed: 1, offsets: [0, 1, 2] }; - + /** Asset.*/ texture: Texture2D; } diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 1b541775f4..389d4339c0 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -46,8 +46,9 @@ export function ignoreClone(target: object, propertyKey: string): void { /** * Class decorator — the default clone mode for instances of the decorated type * (unregistered non-container types fall back to Assignment). - * A type registered `Deep` must construct without arguments: the gate creates preset-less - * instances bare and then populates every field. + * A `Deep` type cloned without a same-type preset (e.g. as a container element) must construct + * bare — the gate creates the instance argument-less and then populates every field; host-bound + * types lacking a preset fail with a named error. * @param mode - The clone mode applied to instances of the decorated type */ export function defaultCloneMode(mode: CloneMode) { diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index 8910aac8ef..385bd35282 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -4,7 +4,7 @@ export enum CloneMode { /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */ Ignore, - /** Share the reference; a ref-counted resource is kept alive by the clone. */ + /** Share the reference; a counted resource shared at a component's top-level slot is kept alive by the clone. */ Assignment, /** Remap an Entity / Component reference to its clone within the cloned subtree. */ Remap, diff --git a/packages/core/src/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts index 9623f24c3f..1999e1d555 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -563,7 +563,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 { @@ -594,7 +594,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]); } @@ -610,7 +610,7 @@ export class ShaderData implements IReferable, IClone { } cloneTo(target: ShaderData): void { - CloneManager.deepCloneObject(this._macroCollection, target._macroCollection, new Map()); + CloneManager.deepCloneObject(this._macroCollection, target._macroCollection, new Map()); Object.assign(target._macroMap, this._macroMap); const referCount = target._getReferCount(); const propertyValueMap = this._propertyValueMap; @@ -626,7 +626,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 && ShaderData._addTexturesReferCount(cloned, referCount); } else { const targetProperty = targetPropertyValueMap[k]; if (targetProperty) { @@ -719,8 +721,20 @@ 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 && ShaderData._addTexturesReferCount(property, value); + } + } + + /** + * Counted texture content may be a single texture or a texture array; both cascade alike. + */ + private static _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/tests/src/core/ShaderDataRefCount.test.ts b/tests/src/core/ShaderDataRefCount.test.ts index a61f0c73fd..84027d2014 100644 --- a/tests/src/core/ShaderDataRefCount.test.ts +++ b/tests/src/core/ShaderDataRefCount.test.ts @@ -110,4 +110,51 @@ describe("ShaderData / Material refCount cascade", () => { 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); + }); }); From 7027eecd35a5067bdb95baf8395b7a428dcd4e96 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 10 Jul 2026 20:59:06 +0800 Subject: [PATCH 054/109] test(clone): pin upstream orbital fields deep-cloning via the type default Upstream #3049 guaranteed orbital curve/offset cloning with @deepClone; the merge dropped those decorators as redundant under type-driven Deep. Pin the equivalence with identity + value assertions so a regression in the type default surfaces here instead of in particle rendering. Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneManager.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index ecbae59a93..3fa374e74b 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1350,6 +1350,25 @@ describe("Clone remap", async () => { 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.offset.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.offset).not.eq(vol.offset); + expect(clonedVol.offset.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"); From ec48059110fe588a9ab259250a2117d65bde594b Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 10 Jul 2026 21:01:37 +0800 Subject: [PATCH 055/109] test(clone): fix orbital pin to use the public centerOffset accessor Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneManager.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 3fa374e74b..c88f06d197 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1356,15 +1356,15 @@ describe("Clone remap", async () => { const renderer = parent.addComponent(ParticleRenderer); const vol = renderer.generator.velocityOverLifetime; vol.orbitalX = new ParticleCompositeCurve(1, 2); - vol.offset.set(3, 4, 5); + 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.offset).not.eq(vol.offset); - expect(clonedVol.offset.z).eq(5); + expect(clonedVol.centerOffset).not.eq(vol.centerOffset); + expect(clonedVol.centerOffset.z).eq(5); rootEntity.destroy(); }); From cd3bad1dc2c6e526df5f4e86181b19581309dfc2 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 10 Jul 2026 22:15:22 +0800 Subject: [PATCH 056/109] docs(clone): document defaultCloneMode registration for custom types The decorator is public API (37 engine classes use it) and the removal of shallowClone pushes users toward type registration, yet the guide only covered field decorators. Adds a section with the registration example, the Assignment fallback motivation, and the bare-construction contract for Deep types. Co-Authored-By: Claude Fable 5 --- docs/en/core/clone.mdx | 20 ++++++++++++++++++++ docs/zh/core/clone.mdx | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index c3f4f0f999..0e659f0613 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -145,5 +145,25 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - `deepClone` cannot deep clone engine-bound objects: `Entity` / `Component` references fall back to remapping and assets fall back to sharing, both with a warning. If you need a real copy of an asset, use the asset's own clone API. - If the clone decorators do not meet the requirements, you can implement the [_cloneTo()](/apis/design/#IClone-cloneTo) method to add custom cloning. +### Registering Clone Semantics for Custom Types + +The decorators above act on fields. If you define a reusable value type — a settings object, a curve, a damage profile — you can register clone semantics on the type itself with `@defaultCloneMode`, so every field, array, or map holding it clones correctly without per-field annotation: + +```typescript +import { CloneMode, defaultCloneMode, Vector3 } from "@galacean/engine"; + +@defaultCloneMode(CloneMode.Deep) +class DamageProfile { + base = 10; + falloff = new Vector3(1, 0.5, 0.25); +} +``` + +An unregistered class instance is shared by reference (`Assignment`) — registration is what turns it into an independent copy per clone. Field decorators still override the registered default on individual fields. + + +A type registered `Deep` must be constructible without arguments: when the clone system meets such a value in a container (array, `Map`, plain object), it creates the copy with `new Type()` and then fills every field. A constructor that requires arguments fails at clone time with an error naming this contract. + + ## 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. The class holding the field is responsible for releasing that reference when it is destroyed — built-in components already handle this; for custom scripts, release the resources you hold in `onDestroy`. diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index e713ee8cbe..0b88e996c0 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -146,5 +146,25 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - `deepClone` 无法深克隆引擎绑定的对象:`Entity` / `Component` 引用会回退为重映射,资产会回退为共享,二者都会打印告警。如果需要真正复制一份资产,请使用资产自身的克隆 API。 - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 +### 为自定义类型注册克隆语义 + +上面的装饰器作用于字段。如果你定义了一个可复用的值类型 —— 配置对象、曲线、伤害档案等,可以用 `@defaultCloneMode` 在类型上注册克隆语义,这样持有它的任何字段、数组或 Map 都能正确克隆,无需逐字段标注: + +```typescript +import { CloneMode, defaultCloneMode, Vector3 } from "@galacean/engine"; + +@defaultCloneMode(CloneMode.Deep) +class DamageProfile { + base = 10; + falloff = new Vector3(1, 0.5, 0.25); +} +``` + +未注册的类实例默认共享引用(`Assignment`)—— 注册正是让它在每次克隆时变成独立拷贝的开关。字段装饰器依然可以在具体字段上覆盖注册的默认值。 + + +注册为 `Deep` 的类型必须支持无参构造:克隆系统在容器(数组、`Map`、普通对象)里遇到这类值时,会先 `new Type()` 创建实例再逐字段填充。构造函数带必需参数会在克隆时报错,错误信息会指明这条契约。 + + ## 克隆与资产引用计数 克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数。持有该字段的类负责在销毁时释放这次引用 —— 内置组件已由引擎处理;自定义脚本请在 `onDestroy` 中释放自己持有的资源。 From 033f938259f69d502833fc81c08e23a1aaaf505c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 17 Jul 2026 19:01:00 +0800 Subject: [PATCH 057/109] refactor(clone): fold Deep registration into a public DataObject base class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the type registry should not be a public concept. Cloneable data types now extend DataObject (Deep via inherited marker, plus a public clone()); the 30 per-class @defaultCloneMode(Deep) decorators are gone, defaultCloneMode/CloneMode leave the public API, and the docs teach 'extend DataObject' instead of mode registration. Unknown classes still share by reference — platform objects (DOM, adapter canvas) must never be deep-cloned, so the safe default stays. Intrinsic engine defaults keep their internal base-class markers (Entity/Component remap, ReferResource share, UpdateFlag family ignore); math value types keep the closed internal registration list. Skin loses its never-used EngineObject inheritance (super(null) since init) and MainModule — which never extended ParticleGeneratorModule — now extends DataObject directly. Guard tests enumerate the DataObject family. Co-Authored-By: Claude Fable 5 --- docs/en/core/clone.mdx | 13 ++++++------- docs/zh/core/clone.mdx | 13 ++++++------- packages/core/src/Signal.ts | 7 +++---- packages/core/src/VirtualCamera.ts | 7 +++---- packages/core/src/base/DataObject.ts | 19 +++++++++++++++++++ packages/core/src/base/index.ts | 1 + packages/core/src/index.ts | 3 +-- packages/core/src/mesh/Skin.ts | 10 ++++------ .../core/src/particle/ParticleGenerator.ts | 8 ++++---- packages/core/src/particle/modules/Burst.ts | 7 +++---- .../core/src/particle/modules/MainModule.ts | 8 ++++---- .../modules/ParticleCompositeCurve.ts | 8 ++++---- .../modules/ParticleCompositeGradient.ts | 7 +++---- .../src/particle/modules/ParticleCurve.ts | 12 ++++++------ .../modules/ParticleGeneratorModule.ts | 8 ++++---- .../src/particle/modules/ParticleGradient.ts | 16 ++++++++-------- .../core/src/particle/modules/SubEmitter.ts | 7 +++---- .../src/particle/modules/shape/BaseShape.ts | 8 ++++---- packages/core/src/physics/joint/Joint.ts | 7 +++---- .../core/src/physics/joint/JointLimits.ts | 6 ++---- packages/core/src/physics/joint/JointMotor.ts | 6 ++---- .../core/src/physics/shape/ColliderShape.ts | 8 ++++---- .../core/src/postProcess/PostProcessEffect.ts | 8 +++----- .../postProcess/PostProcessEffectParameter.ts | 7 +++---- packages/core/src/shader/ShaderData.ts | 12 ++++++------ packages/core/src/shader/state/BlendState.ts | 6 ++---- packages/core/src/shader/state/DepthState.ts | 6 ++---- packages/core/src/shader/state/RasterState.ts | 6 ++---- packages/core/src/shader/state/RenderState.ts | 6 ++---- .../shader/state/RenderTargetBlendState.ts | 6 ++---- .../core/src/shader/state/StencilState.ts | 6 ++---- .../interactive/transition/Transition.ts | 5 ++--- tests/src/core/CloneManager.test.ts | 19 ++++++++++--------- 33 files changed, 133 insertions(+), 143 deletions(-) create mode 100644 packages/core/src/base/DataObject.ts diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 0e659f0613..e3a42cb0a8 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -145,24 +145,23 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - `deepClone` cannot deep clone engine-bound objects: `Entity` / `Component` references fall back to remapping and assets fall back to sharing, both with a warning. If you need a real copy of an asset, use the asset's own clone API. - If the clone decorators do not meet the requirements, you can implement the [_cloneTo()](/apis/design/#IClone-cloneTo) method to add custom cloning. -### Registering Clone Semantics for Custom Types +### Custom Data Types -The decorators above act on fields. If you define a reusable value type — a settings object, a curve, a damage profile — you can register clone semantics on the type itself with `@defaultCloneMode`, so every field, array, or map holding it clones correctly without per-field annotation: +The decorators above act on fields. If you define a reusable data type — a settings object, a curve, a damage profile — extend `DataObject`: wherever an instance is held (a component field, an array, a map), cloning produces an independent deep copy, and the instance gains a `clone()` method: ```typescript -import { CloneMode, defaultCloneMode, Vector3 } from "@galacean/engine"; +import { DataObject, Vector3 } from "@galacean/engine"; -@defaultCloneMode(CloneMode.Deep) -class DamageProfile { +class DamageProfile extends DataObject { base = 10; falloff = new Vector3(1, 0.5, 0.25); } ``` -An unregistered class instance is shared by reference (`Assignment`) — registration is what turns it into an independent copy per clone. Field decorators still override the registered default on individual fields. +A class that does not extend `DataObject` is shared by reference — extending it is what turns instances into independent copies per clone. Field decorators still override this on individual fields. -A type registered `Deep` must be constructible without arguments: when the clone system meets such a value in a container (array, `Map`, plain object), it creates the copy with `new Type()` and then fills every field. A constructor that requires arguments fails at clone time with an error naming this contract. +A `DataObject` subclass should stay constructible without arguments: when the clone system meets an instance in a container (array, `Map`, plain object), it creates the copy with `new Type()` and then fills every field. A constructor that requires arguments fails at clone time with an error naming this contract. ## Cloning and Asset Reference Counting diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index 0b88e996c0..ef2600155d 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -146,24 +146,23 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - `deepClone` 无法深克隆引擎绑定的对象:`Entity` / `Component` 引用会回退为重映射,资产会回退为共享,二者都会打印告警。如果需要真正复制一份资产,请使用资产自身的克隆 API。 - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 -### 为自定义类型注册克隆语义 +### 自定义数据类型 -上面的装饰器作用于字段。如果你定义了一个可复用的值类型 —— 配置对象、曲线、伤害档案等,可以用 `@defaultCloneMode` 在类型上注册克隆语义,这样持有它的任何字段、数组或 Map 都能正确克隆,无需逐字段标注: +上面的装饰器作用于字段。如果你定义了一个可复用的数据类型 —— 配置对象、曲线、伤害档案等,让它继承 `DataObject`:无论实例被组件字段、数组还是 Map 持有,克隆都会产生独立的深拷贝,实例同时获得 `clone()` 方法: ```typescript -import { CloneMode, defaultCloneMode, Vector3 } from "@galacean/engine"; +import { DataObject, Vector3 } from "@galacean/engine"; -@defaultCloneMode(CloneMode.Deep) -class DamageProfile { +class DamageProfile extends DataObject { base = 10; falloff = new Vector3(1, 0.5, 0.25); } ``` -未注册的类实例默认共享引用(`Assignment`)—— 注册正是让它在每次克隆时变成独立拷贝的开关。字段装饰器依然可以在具体字段上覆盖注册的默认值。 +不继承 `DataObject` 的类实例默认共享引用 —— 继承正是让它在每次克隆时变成独立拷贝的开关。字段装饰器依然可以在具体字段上覆盖这一行为。 -注册为 `Deep` 的类型必须支持无参构造:克隆系统在容器(数组、`Map`、普通对象)里遇到这类值时,会先 `new Type()` 创建实例再逐字段填充。构造函数带必需参数会在克隆时报错,错误信息会指明这条契约。 +`DataObject` 的子类应支持无参构造:克隆系统在容器(数组、`Map`、普通对象)里遇到实例时,会先 `new Type()` 创建拷贝再逐字段填充。构造函数带必需参数会在克隆时报错,错误信息会指明这条契约。 ## 克隆与资产引用计数 diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index 3cdfc96bdc..771ad48c91 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,14 +1,13 @@ +import { DataObject } from "./base/DataObject"; import { Component } from "./Component"; -import { CloneManager, defaultCloneMode } from "./clone/CloneManager"; -import { CloneMode } from "./clone/enums/CloneMode"; +import { CloneManager } 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 */ -@defaultCloneMode(CloneMode.Deep) -export class Signal { +export class Signal extends DataObject { private _listeners: SafeLoopArray> = new SafeLoopArray>(); /** diff --git a/packages/core/src/VirtualCamera.ts b/packages/core/src/VirtualCamera.ts index 8e49cf11ce..2fc88b3c8f 100644 --- a/packages/core/src/VirtualCamera.ts +++ b/packages/core/src/VirtualCamera.ts @@ -1,12 +1,11 @@ +import { DataObject } from "./base/DataObject"; import { Matrix, Vector3 } from "@galacean/engine-math"; -import { CloneMode } from "./clone/enums/CloneMode"; -import { ignoreClone, defaultCloneMode } from "./clone/CloneManager"; +import { ignoreClone } from "./clone/CloneManager"; /** * @internal */ -@defaultCloneMode(CloneMode.Deep) -export class VirtualCamera { +export class VirtualCamera extends DataObject { isOrthographic: boolean = false; nearClipPlane: number = 0.1; farClipPlane: number = 100; diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts new file mode 100644 index 0000000000..04b4bba343 --- /dev/null +++ b/packages/core/src/base/DataObject.ts @@ -0,0 +1,19 @@ +import { CloneManager, defaultCloneMode } from "../clone/CloneManager"; +import { CloneMode } from "../clone/enums/CloneMode"; + +/** + * Base class for cloneable 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 should stay constructible without arguments: the clone system creates + * preset-less copies bare and then populates every field. + */ +@defaultCloneMode(CloneMode.Deep) +export abstract class DataObject { + /** + * Create an independent deep copy of this object. + * @returns The cloned object + */ + clone(): this { + return CloneManager._cloneValue(this, undefined, new Map()); + } +} 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/index.ts b/packages/core/src/index.ts index 308c4751be..e3e1f6b3b6 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 * from "./clone/enums/CloneMode"; +export { CloneManager, deepClone, assignmentClone, ignoreClone } from "./clone/CloneManager"; export * from "./renderingHardwareInterface/index"; export * from "./physics/index"; export * from "./Utils"; diff --git a/packages/core/src/mesh/Skin.ts b/packages/core/src/mesh/Skin.ts index b48ce776ed..673632acf4 100644 --- a/packages/core/src/mesh/Skin.ts +++ b/packages/core/src/mesh/Skin.ts @@ -1,17 +1,15 @@ import { Matrix } from "@galacean/engine-math"; -import { CloneMode } from "../clone/enums/CloneMode"; import { Entity } from "../Entity"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { Utils } from "../Utils"; -import { EngineObject } from "../base/EngineObject"; -import { ignoreClone, defaultCloneMode } from "../clone/CloneManager"; +import { DataObject } from "../base/DataObject"; +import { ignoreClone } from "../clone/CloneManager"; import { SkinnedMeshRenderer } from "./SkinnedMeshRenderer"; /** * Skin used for skinned mesh renderer. */ -@defaultCloneMode(CloneMode.Deep) -export class Skin extends EngineObject { +export class Skin extends DataObject { /** Inverse bind matrices. */ inverseBindMatrices = new Array(); @@ -63,7 +61,7 @@ export class Skin extends EngineObject { } constructor(public name: string) { - super(null); + super(); } /** diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts index 30f39fb818..1a279f6e84 100644 --- a/packages/core/src/particle/ParticleGenerator.ts +++ b/packages/core/src/particle/ParticleGenerator.ts @@ -1,7 +1,7 @@ +import { DataObject } from "../base/DataObject"; import { BoundingBox, Color, MathUtil, Matrix, Quaternion, Vector2, Vector3 } from "@galacean/engine-math"; -import { CloneMode } from "../clone/enums/CloneMode"; import { Transform } from "../Transform"; -import { ignoreClone, defaultCloneMode } from "../clone/CloneManager"; +import { ignoreClone } from "../clone/CloneManager"; import { Primitive } from "../graphic/Primitive"; import { SubMesh } from "../graphic/SubMesh"; import { SubPrimitive } from "../graphic/SubPrimitive"; @@ -43,8 +43,7 @@ import { SubEmittersModule } from "./modules/SubEmittersModule"; /** * Particle Generator. */ -@defaultCloneMode(CloneMode.Deep) -export class ParticleGenerator { +export class ParticleGenerator extends DataObject { private static _tempVector20 = new Vector2(); private static _tempVector21 = new Vector2(); private static _tempVector22 = new Vector2(); @@ -200,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/modules/Burst.ts b/packages/core/src/particle/modules/Burst.ts index 9f07aedc1b..b885ca12c4 100644 --- a/packages/core/src/particle/modules/Burst.ts +++ b/packages/core/src/particle/modules/Burst.ts @@ -1,12 +1,10 @@ -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; +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 */ -@defaultCloneMode(CloneMode.Deep) -export class Burst { +export class Burst extends DataObject { public time: number; public count: ParticleCompositeCurve; @@ -50,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/MainModule.ts b/packages/core/src/particle/modules/MainModule.ts index f0b1aa6e4d..c736dd046a 100644 --- a/packages/core/src/particle/modules/MainModule.ts +++ b/packages/core/src/particle/modules/MainModule.ts @@ -1,7 +1,7 @@ +import { DataObject } from "../../base/DataObject"; import { Color, Rand, Vector3, Vector4 } from "@galacean/engine-math"; -import { CloneMode } from "../../clone/enums/CloneMode"; import { TransformModifyFlags } from "../../Transform"; -import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ICustomClone } from "../../clone/ComponentCloner"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -12,8 +12,7 @@ import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; import { ParticleCompositeGradient } from "./ParticleCompositeGradient"; -@defaultCloneMode(CloneMode.Deep) -export class MainModule implements ICustomClone { +export class MainModule extends DataObject implements ICustomClone { private _tempVector40 = new Vector4(); private static _vector3One = new Vector3(1, 1, 1); @@ -240,6 +239,7 @@ export class MainModule implements ICustomClone { * @internal */ constructor(generator: ParticleGenerator) { + super(); this._generator = generator; this.startLifetime = new ParticleCompositeCurve(5); diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts index 0b8b609d01..0e761f6ff0 100644 --- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts +++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts @@ -1,6 +1,6 @@ +import { DataObject } from "../../base/DataObject"; import { Vector2 } from "@galacean/engine-math"; -import { CloneMode } from "../../clone/enums/CloneMode"; -import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { UpdateFlagManager } from "../../UpdateFlagManager"; import { ParticleCurveMode } from "../enums/ParticleCurveMode"; import { CurveKey, ParticleCurve } from "./ParticleCurve"; @@ -8,8 +8,7 @@ import { CurveKey, ParticleCurve } from "./ParticleCurve"; /** * Particle composite curve. */ -@defaultCloneMode(CloneMode.Deep) -export class ParticleCompositeCurve { +export class ParticleCompositeCurve extends DataObject { private static _minMaxRange = new Vector2(); private _updateManager = new UpdateFlagManager(); @@ -141,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 134dbff26d..d76c59f2ba 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -1,14 +1,12 @@ +import { DataObject } from "../../base/DataObject"; import { Color } from "@galacean/engine-math"; -import { CloneMode } from "../../clone/enums/CloneMode"; -import { defaultCloneMode } from "../../clone/CloneManager"; import { ParticleGradientMode } from "../enums/ParticleGradientMode"; import { ParticleGradient } from "./ParticleGradient"; /** * Particle composite gradient. */ -@defaultCloneMode(CloneMode.Deep) -export class ParticleCompositeGradient { +export class ParticleCompositeGradient extends DataObject { private static _tempColor = new Color(); /** The gradient mode. */ @@ -76,6 +74,7 @@ export class ParticleCompositeGradient { constructor(gradientMin: ParticleGradient, gradientMax: ParticleGradient); constructor(constantOrGradient?: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) { + super(); // No argument: the field defaults already form a valid constant-mode state (clone gate // constructs container elements bare, then populates every field). if (!constantOrGradient) return; diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts index 08e1d666f5..a84de05a57 100644 --- a/packages/core/src/particle/modules/ParticleCurve.ts +++ b/packages/core/src/particle/modules/ParticleCurve.ts @@ -1,12 +1,11 @@ +import { DataObject } from "../../base/DataObject"; import { UpdateFlagManager } from "../../UpdateFlagManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; -import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; /** * Particle curve. */ -@defaultCloneMode(CloneMode.Deep) -export class ParticleCurve { +export class ParticleCurve extends DataObject { private _updateManager = new UpdateFlagManager(); private _keys = new Array(); @ignoreClone @@ -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. */ -@defaultCloneMode(CloneMode.Deep) -export class CurveKey { +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 ecd7288280..4353b4bf8d 100644 --- a/packages/core/src/particle/modules/ParticleGeneratorModule.ts +++ b/packages/core/src/particle/modules/ParticleGeneratorModule.ts @@ -1,5 +1,5 @@ -import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; +import { DataObject } from "../../base/DataObject"; +import { ignoreClone } from "../../clone/CloneManager"; import { ShaderData, ShaderMacro } from "../../shader"; import { ParticleGenerator } from "../ParticleGenerator"; import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; @@ -7,8 +7,7 @@ import { ParticleCompositeCurve } from "./ParticleCompositeCurve"; /** * Particle generator module. */ -@defaultCloneMode(CloneMode.Deep) -export abstract class ParticleGeneratorModule { +export abstract class ParticleGeneratorModule extends DataObject { /** @internal */ @ignoreClone _generator: ParticleGenerator; @@ -30,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 3655659f61..6daf6dba6b 100644 --- a/packages/core/src/particle/modules/ParticleGradient.ts +++ b/packages/core/src/particle/modules/ParticleGradient.ts @@ -1,12 +1,11 @@ +import { DataObject } from "../../base/DataObject"; import { Color } from "@galacean/engine-math"; -import { CloneMode } from "../../clone/enums/CloneMode"; -import { ignoreClone, defaultCloneMode } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; /** * Particle gradient. */ -@defaultCloneMode(CloneMode.Deep) -export class ParticleGradient { +export class ParticleGradient extends DataObject { private _colorKeys: GradientColorKey[] = []; private _alphaKeys: GradientAlphaKey[] = []; @ignoreClone @@ -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,8 +286,7 @@ export class ParticleGradient { /** * The color key of the particle gradient. */ -@defaultCloneMode(CloneMode.Deep) -export class GradientColorKey { +export class GradientColorKey extends DataObject { /** @internal */ _onValueChanged: () => void = null; @@ -325,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 @@ -335,8 +335,7 @@ export class GradientColorKey { /** * The alpha key of the particle gradient. */ -@defaultCloneMode(CloneMode.Deep) -export class GradientAlphaKey { +export class GradientAlphaKey extends DataObject { /** @internal */ _onValueChanged: () => void = null; @@ -373,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/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index a893371b48..e7939e98ac 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -1,5 +1,5 @@ -import { defaultCloneMode, ignoreClone } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; +import { DataObject } from "../../base/DataObject"; +import { ignoreClone } from "../../clone/CloneManager"; import { ParticleRenderer } from "../ParticleRenderer"; import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty"; import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType"; @@ -9,8 +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. */ -@defaultCloneMode(CloneMode.Deep) -export class SubEmitter { +export class SubEmitter extends DataObject { /** Bitmask of properties inherited from the parent particle. */ inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None; diff --git a/packages/core/src/particle/modules/shape/BaseShape.ts b/packages/core/src/particle/modules/shape/BaseShape.ts index 029bbed8f1..c089b47b30 100644 --- a/packages/core/src/particle/modules/shape/BaseShape.ts +++ b/packages/core/src/particle/modules/shape/BaseShape.ts @@ -1,14 +1,13 @@ +import { DataObject } from "../../../base/DataObject"; import { BoundingBox, MathUtil, Matrix, Quaternion, Rand, Vector2, Vector3 } from "@galacean/engine-math"; -import { CloneMode } from "../../../clone/enums/CloneMode"; import { ParticleShapeType } from "./enums/ParticleShapeType"; import { UpdateFlagManager } from "../../../UpdateFlagManager"; -import { ignoreClone, defaultCloneMode } from "../../../clone/CloneManager"; +import { ignoreClone } from "../../../clone/CloneManager"; /** * Base class for all particle shapes. */ -@defaultCloneMode(CloneMode.Deep) -export abstract class BaseShape { +export abstract class BaseShape extends DataObject { /** @internal */ static _tempVector20 = new Vector2(); /** @internal */ @@ -103,6 +102,7 @@ export abstract class BaseShape { } constructor() { + super(); // @ts-ignore this._position._onValueChanged = this._onTransformChanged; // @ts-ignore diff --git a/packages/core/src/physics/joint/Joint.ts b/packages/core/src/physics/joint/Joint.ts index 02d9b6368b..1f721d93a8 100644 --- a/packages/core/src/physics/joint/Joint.ts +++ b/packages/core/src/physics/joint/Joint.ts @@ -1,11 +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 { defaultCloneMode, ignoreClone } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; +import { ignoreClone } from "../../clone/CloneManager"; import { Collider } from "../Collider"; import { DynamicCollider } from "../DynamicCollider"; @@ -320,8 +320,7 @@ enum AnchorOwner { /** * @internal */ -@defaultCloneMode(CloneMode.Deep) -class JointColliderInfo { +class JointColliderInfo extends DataObject { collider: Collider = null; anchor = new Vector3(); actualAnchor = new Vector3(); diff --git a/packages/core/src/physics/joint/JointLimits.ts b/packages/core/src/physics/joint/JointLimits.ts index 6db82b58b4..4369abb48d 100644 --- a/packages/core/src/physics/joint/JointLimits.ts +++ b/packages/core/src/physics/joint/JointLimits.ts @@ -1,12 +1,10 @@ -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; +import { DataObject } from "../../base/DataObject"; import { UpdateFlagManager } from "../../UpdateFlagManager"; /** * JointLimits is used to limit the joints angle. */ -@defaultCloneMode(CloneMode.Deep) -export class JointLimits { +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 cfa82572c4..73024929fa 100644 --- a/packages/core/src/physics/joint/JointMotor.ts +++ b/packages/core/src/physics/joint/JointMotor.ts @@ -1,12 +1,10 @@ -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; +import { DataObject } from "../../base/DataObject"; import { UpdateFlagManager } from "../../UpdateFlagManager"; /** * The JointMotor is used to motorize a joint. */ -@defaultCloneMode(CloneMode.Deep) -export class JointMotor { +export class JointMotor extends DataObject { /** @internal */ _updateFlagManager = new UpdateFlagManager(); diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 0e6637ec6b..63aecd7a22 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -1,18 +1,17 @@ +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 { defaultCloneMode, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; import { ICustomClone } from "../../clone/ComponentCloner"; -import { CloneMode } from "../../clone/enums/CloneMode"; import { Engine } from "../../Engine"; import { ColliderShapeChangeFlag } from "../enums/ColliderShapeChangeFlag"; /** * Abstract class for collider shapes. */ -@defaultCloneMode(CloneMode.Deep) -export abstract class ColliderShape implements ICustomClone { +export abstract class ColliderShape extends DataObject implements ICustomClone { private static _idGenerator: number = 0; /** @internal */ @@ -127,6 +126,7 @@ export abstract class ColliderShape implements ICustomClone { } protected constructor() { + super(); this._material = new PhysicsMaterial(); this._id = ColliderShape._idGenerator++; diff --git a/packages/core/src/postProcess/PostProcessEffect.ts b/packages/core/src/postProcess/PostProcessEffect.ts index 943a6c01ad..d976861d04 100644 --- a/packages/core/src/postProcess/PostProcessEffect.ts +++ b/packages/core/src/postProcess/PostProcessEffect.ts @@ -1,12 +1,10 @@ +import { DataObject } from "../base/DataObject"; import { PostProcessEffectParameter } from "./PostProcessEffectParameter"; -import { defaultCloneMode } from "../clone/CloneManager"; -import { CloneMode } from "../clone/enums/CloneMode"; /** * The base class for post process effect. */ -@defaultCloneMode(CloneMode.Deep) -export class PostProcessEffect { +export class PostProcessEffect extends DataObject { private _enabled = true; private _parameters: PostProcessEffectParameter[] = []; private _parameterInitialized = false; @@ -59,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 efce5533ff..15e16ed442 100644 --- a/packages/core/src/postProcess/PostProcessEffectParameter.ts +++ b/packages/core/src/postProcess/PostProcessEffectParameter.ts @@ -1,6 +1,5 @@ +import { DataObject } from "../base/DataObject"; import { Color, MathUtil, Vector2, Vector3, Vector4 } from "@galacean/engine-math"; -import { defaultCloneMode } from "../clone/CloneManager"; -import { CloneMode } from "../clone/enums/CloneMode"; import { Texture } from "../texture"; /** @@ -8,8 +7,7 @@ import { Texture } from "../texture"; * @remarks * The parameter will be mixed to a final value and be used in post process manager. */ -@defaultCloneMode(CloneMode.Deep) -export abstract class PostProcessEffectParameter { +export abstract class PostProcessEffectParameter extends DataObject { /** * Whether the parameter is enabled. */ @@ -30,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 1999e1d555..85e31460dd 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -1,8 +1,8 @@ +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, defaultCloneMode, ignoreClone } from "../clone/CloneManager"; -import { CloneMode } from "../clone/enums/CloneMode"; +import { CloneManager, ignoreClone } from "../clone/CloneManager"; import { Texture } from "../texture/Texture"; import { ShaderMacro } from "./ShaderMacro"; import { ShaderMacroCollection } from "./ShaderMacroCollection"; @@ -13,8 +13,7 @@ import { ShaderPropertyType } from "./enums/ShaderPropertyType"; /** * Shader data collection,Correspondence includes shader properties data and macros data. */ -@defaultCloneMode(CloneMode.Deep) -export class ShaderData implements IReferable, IClone { +export class ShaderData extends DataObject implements IReferable, IClone { /** @internal */ @ignoreClone _group: ShaderDataGroup; @@ -36,6 +35,7 @@ export class ShaderData implements IReferable, IClone { * @internal */ constructor(group: ShaderDataGroup) { + super(); this._group = group; } @@ -603,10 +603,10 @@ export class ShaderData implements IReferable, IClone { } } - clone(): ShaderData { + override clone(): this { const shaderData = new ShaderData(this._group); this.cloneTo(shaderData); - return shaderData; + return shaderData; } cloneTo(target: ShaderData): void { diff --git a/packages/core/src/shader/state/BlendState.ts b/packages/core/src/shader/state/BlendState.ts index 054749584e..443cd1acbe 100644 --- a/packages/core/src/shader/state/BlendState.ts +++ b/packages/core/src/shader/state/BlendState.ts @@ -1,9 +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 { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { BlendFactor } from "../enums/BlendFactor"; @@ -16,8 +15,7 @@ import { RenderTargetBlendState } from "./RenderTargetBlendState"; /** * Blend state. */ -@defaultCloneMode(CloneMode.Deep) -export class BlendState { +export class BlendState extends DataObject { private static _getGLBlendFactor(rhi: IHardwareRenderer, blendFactor: BlendFactor): number { const gl = rhi.gl; diff --git a/packages/core/src/shader/state/DepthState.ts b/packages/core/src/shader/state/DepthState.ts index cd205f3445..15b221efb9 100644 --- a/packages/core/src/shader/state/DepthState.ts +++ b/packages/core/src/shader/state/DepthState.ts @@ -1,7 +1,6 @@ +import { DataObject } from "../../base/DataObject"; import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { CompareFunction } from "../enums/CompareFunction"; @@ -11,8 +10,7 @@ import { RenderState } from "./RenderState"; /** * Depth state. */ -@defaultCloneMode(CloneMode.Deep) -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 adfd4be8df..ddebc3afa8 100644 --- a/packages/core/src/shader/state/RasterState.ts +++ b/packages/core/src/shader/state/RasterState.ts @@ -1,7 +1,6 @@ +import { DataObject } from "../../base/DataObject"; import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { CullMode } from "../enums/CullMode"; @@ -11,8 +10,7 @@ import { RenderState } from "./RenderState"; /** * Raster state. */ -@defaultCloneMode(CloneMode.Deep) -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 50c5e5e9ee..9f67e4b37a 100644 --- a/packages/core/src/shader/state/RenderState.ts +++ b/packages/core/src/shader/state/RenderState.ts @@ -1,8 +1,7 @@ +import { DataObject } from "../../base/DataObject"; import { ShaderData, ShaderProperty } from ".."; import { RenderStateElementMap } from "../../BasicResources"; import { Engine } from "../../Engine"; -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; import { RenderQueueType } from "../enums/RenderQueueType"; import { RenderStateElementKey } from "../enums/RenderStateElementKey"; import { BlendState } from "./BlendState"; @@ -13,8 +12,7 @@ import { StencilState } from "./StencilState"; /** * Render state. */ -@defaultCloneMode(CloneMode.Deep) -export class RenderState { +export class RenderState extends DataObject { /** Blend state. */ readonly blendState: BlendState = new BlendState(); /** Depth state. */ diff --git a/packages/core/src/shader/state/RenderTargetBlendState.ts b/packages/core/src/shader/state/RenderTargetBlendState.ts index f6073735c1..cdb32b6c17 100644 --- a/packages/core/src/shader/state/RenderTargetBlendState.ts +++ b/packages/core/src/shader/state/RenderTargetBlendState.ts @@ -1,5 +1,4 @@ -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; +import { DataObject } from "../../base/DataObject"; import { BlendOperation } from "../enums/BlendOperation"; import { BlendFactor } from "../enums/BlendFactor"; import { ColorWriteMask } from "../enums/ColorWriteMask"; @@ -7,8 +6,7 @@ import { ColorWriteMask } from "../enums/ColorWriteMask"; /** * The blend state of the render target. */ -@defaultCloneMode(CloneMode.Deep) -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 9cadb8872d..9a77ec38cf 100644 --- a/packages/core/src/shader/state/StencilState.ts +++ b/packages/core/src/shader/state/StencilState.ts @@ -1,7 +1,6 @@ +import { DataObject } from "../../base/DataObject"; import { IHardwareRenderer } from "@galacean/engine-design"; import { RenderStateElementMap } from "../../BasicResources"; -import { defaultCloneMode } from "../../clone/CloneManager"; -import { CloneMode } from "../../clone/enums/CloneMode"; import { ShaderData } from "../ShaderData"; import { ShaderProperty } from "../ShaderProperty"; import { CompareFunction } from "../enums/CompareFunction"; @@ -12,8 +11,7 @@ import { RenderState } from "./RenderState"; /** * Stencil state. */ -@defaultCloneMode(CloneMode.Deep) -export class StencilState { +export class StencilState extends DataObject { private static _getGLCompareFunction(rhi: IHardwareRenderer, compareFunction: CompareFunction): number { const gl = rhi.gl; diff --git a/packages/ui/src/component/interactive/transition/Transition.ts b/packages/ui/src/component/interactive/transition/Transition.ts index 47639d736d..01794f60a7 100644 --- a/packages/ui/src/component/interactive/transition/Transition.ts +++ b/packages/ui/src/component/interactive/transition/Transition.ts @@ -1,15 +1,14 @@ -import { CloneMode, Color, ReferResource, Sprite, defaultCloneMode, ignoreClone } from "@galacean/engine"; +import { Color, DataObject, ReferResource, Sprite, ignoreClone } from "@galacean/engine"; import { UIRenderer } from "../../UIRenderer"; import { InteractiveState, UIInteractive } from "../UIInteractive"; /** * The transition behavior of UIInteractive. */ -@defaultCloneMode(CloneMode.Deep) export abstract class Transition< T extends TransitionValueType = TransitionValueType, K extends UIRenderer = UIRenderer -> { +> extends DataObject { /** @internal */ _interactive: UIInteractive; diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index c88f06d197..eb7e04751d 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1,6 +1,6 @@ import { Burst, - CloneMode, + DataObject, Entity, Logger, MeshRenderer, @@ -12,7 +12,6 @@ import { Texture2D, assignmentClone, deepClone, - defaultCloneMode, ignoreClone } from "@galacean/engine-core"; import * as EngineCore from "@galacean/engine-core"; @@ -202,8 +201,7 @@ class AliasedBinaryScript extends Script { b: Float32Array; } -/** User value type registered Assignment without any counting API — must share, never count. */ -@defaultCloneMode(CloneMode.Assignment) +/** Unregistered user value type without any counting API — must share, never count. */ class SharedConfig { value = 1; } @@ -227,12 +225,12 @@ class UnownedPresetScript extends Script { } } -/** User type registered Deep whose constructor dereferences a required argument (contract violation). */ -@defaultCloneMode(CloneMode.Deep) -class ParamDeepConfig { +/** 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; } } @@ -1008,7 +1006,7 @@ describe("Clone remap", async () => { for (const [name, exported] of Object.entries(mathExports)) { if (typeof exported !== "function" || !(exported as any).prototype) continue; if (typeof (exported as any).prototype.copyFrom !== "function") continue; - if ((exported as any).prototype._defaultCloneMode !== CloneMode.Deep) unregistered.push(name); + if ((exported as any).prototype._defaultCloneMode === undefined) unregistered.push(name); } // A math value type missing from CloneManager's registration list falls back to // Assignment sharing — mutable state silently shared between source and clone. @@ -1338,7 +1336,10 @@ describe("Clone remap", async () => { for (const [pkg, ns] of packages) { for (const [name, exported] of Object.entries(ns)) { if (typeof exported !== "function" || !exported.prototype) continue; - if (exported.prototype._defaultCloneMode !== CloneMode.Deep) continue; + // math carries only Deep markers; core/ui Deep types are the DataObject family + const isDeep = + pkg === "math" ? exported.prototype._defaultCloneMode !== undefined : exported.prototype instanceof DataObject; + if (!isDeep) continue; if (exempt.has(name)) continue; try { new exported(); From aea87ef9d9cadc0ffbf933a33dcdc0b1e986325c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 17 Jul 2026 21:44:20 +0800 Subject: [PATCH 058/109] refactor(clone): replace the marker decorator with an instanceof default decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-family prototype markers collapse into one readable decision function: intrinsic families resolve by instanceof (Entity/Component remap, ReferResource share, UpdateFlag family ignore) and the defaultCloneMode decorator is deleted. Only the deep marker stays a string-keyed prototype flag — DataObject is the public extension point, and a string key keeps user data classes cloning correctly across duplicated engine packages where instanceof silently fails. The decision lives in CloneDefaults, a module-graph sink injected into the gate at load: importing the intrinsic classes from CloneManager itself would reorder module evaluation and break extends chains (rollup: 'Super expression must either be null or a function'). Co-Authored-By: Claude Fable 5 --- packages/core/src/Component.ts | 4 +- packages/core/src/Entity.ts | 9 ++-- packages/core/src/UpdateFlag.ts | 3 -- packages/core/src/UpdateFlagManager.ts | 11 ++-- packages/core/src/asset/ReferResource.ts | 3 -- packages/core/src/base/DataObject.ts | 8 +-- packages/core/src/clone/CloneDefaults.ts | 34 ++++++++++++ packages/core/src/clone/CloneManager.ts | 60 ++++++++-------------- packages/core/src/clone/ComponentCloner.ts | 5 +- packages/core/src/clone/enums/CloneMode.ts | 2 +- packages/core/src/index.ts | 2 + packages/core/src/utils/DisorderedArray.ts | 3 -- packages/core/src/utils/SafeLoopArray.ts | 4 -- tests/src/core/CloneManager.test.ts | 6 +-- 14 files changed, 78 insertions(+), 76 deletions(-) create mode 100644 packages/core/src/clone/CloneDefaults.ts diff --git a/packages/core/src/Component.ts b/packages/core/src/Component.ts index 1544e1e063..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 { defaultCloneMode, ignoreClone } from "./clone/CloneManager"; -import { CloneMode } from "./clone/enums/CloneMode"; +import { ignoreClone } from "./clone/CloneManager"; import { Entity } from "./Entity"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; import { Scene } from "./Scene"; @@ -9,7 +8,6 @@ import { Scene } from "./Scene"; /** * The base class of the components. */ -@defaultCloneMode(CloneMode.Remap) export class Component extends EngineObject { /** @internal */ _entity: Entity; diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 1ab32d1c3c..e9270224df 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -10,9 +10,7 @@ import { Transform } from "./Transform"; import { UpdateFlagManager } from "./UpdateFlagManager"; import { ReferResource } from "./asset/ReferResource"; import { EngineObject } from "./base"; -import { defaultCloneMode } from "./clone/CloneManager"; import { ComponentCloner } from "./clone/ComponentCloner"; -import { CloneMode } from "./clone/enums/CloneMode"; import { ActiveChangeFlag } from "./enums/ActiveChangeFlag"; import { EntityModifyFlags } from "./enums/EntityModifyFlags"; import { DisorderedArray } from "./utils/DisorderedArray"; @@ -20,7 +18,6 @@ import { DisorderedArray } from "./utils/DisorderedArray"; /** * Entity, be used as components container. */ -@defaultCloneMode(CloneMode.Remap) export class Entity extends EngineObject { /** @internal */ static _tempComponentConstructors: ComponentConstructor[] = []; @@ -423,7 +420,7 @@ export class Entity extends EngineObject { * @returns Cloned entity */ clone(): Entity { - const cloneMap = new Map(); + const cloneMap = new Map(); const cloneEntity = this._createCloneEntity(cloneMap); this._parseCloneEntity(this, cloneEntity, cloneMap); return cloneEntity; @@ -452,7 +449,7 @@ export class Entity extends EngineObject { * completes for the whole subtree before any value is copied, because a component anywhere in * the tree may reference an entity anywhere else in it. */ - private _createCloneEntity(cloneMap: Map): Entity { + private _createCloneEntity(cloneMap: Map): Entity { const componentConstructors = Entity._tempComponentConstructors; const components = this._components; for (let i = 0, n = components.length; i < n; i++) { @@ -480,7 +477,7 @@ export class Entity extends EngineObject { return cloneEntity; } - private _parseCloneEntity(src: Entity, target: Entity, cloneMap: 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++) { diff --git a/packages/core/src/UpdateFlag.ts b/packages/core/src/UpdateFlag.ts index ee3c1a9e80..4c8ca39806 100644 --- a/packages/core/src/UpdateFlag.ts +++ b/packages/core/src/UpdateFlag.ts @@ -1,12 +1,9 @@ -import { defaultCloneMode } from "./clone/CloneManager"; -import { CloneMode } from "./clone/enums/CloneMode"; import { UpdateFlagManager } from "./UpdateFlagManager"; import { Utils } from "./Utils"; /** * Used to update tags. */ -@defaultCloneMode(CloneMode.Ignore) export abstract class UpdateFlag { /** @internal */ _flagManagers: UpdateFlagManager[] = []; diff --git a/packages/core/src/UpdateFlagManager.ts b/packages/core/src/UpdateFlagManager.ts index 14df3744c3..92675f5210 100644 --- a/packages/core/src/UpdateFlagManager.ts +++ b/packages/core/src/UpdateFlagManager.ts @@ -1,19 +1,16 @@ import { UpdateFlag } from "./UpdateFlag"; import { Utils } from "./Utils"; -import { defaultCloneMode } from "./clone/CloneManager"; -import { CloneMode } from "./clone/enums/CloneMode"; /** * @internal */ -@defaultCloneMode(CloneMode.Ignore) export class UpdateFlagManager { /** Monotonic counter bumped on every `dispatch`; consumers can snapshot it for lazy pull-style cache invalidation. */ version = 0; _updateFlags: UpdateFlag[] = []; - private _listeners: ((type?: number, param?: Object) => void)[] = []; + private _listeners: ((type?: number, param?: object) => void)[] = []; /** * Create a UpdateFlag. @@ -49,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?: object) => void): void { this._listeners.push(listener); } @@ -57,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?: object) => void): void { Utils.removeFromArray(this._listeners, listener); } @@ -66,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?: object): void { this.version++; const updateFlags = this._updateFlags; diff --git a/packages/core/src/asset/ReferResource.ts b/packages/core/src/asset/ReferResource.ts index a317c9c0c9..1460d048e8 100644 --- a/packages/core/src/asset/ReferResource.ts +++ b/packages/core/src/asset/ReferResource.ts @@ -1,13 +1,10 @@ import { EngineObject } from "../base/EngineObject"; import { Engine } from "../Engine"; -import { defaultCloneMode } from "../clone/CloneManager"; -import { CloneMode } from "../clone/enums/CloneMode"; import { IReferable } from "./IReferable"; /** * The base class of assets, with reference counting capability. */ -@defaultCloneMode(CloneMode.Assignment) export abstract class ReferResource extends EngineObject implements IReferable { /** Whether to ignore the garbage collection check, if it is true, it will not be affected by ResourceManager.gc(). */ isGCIgnored: boolean = false; diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts index 04b4bba343..c94fe323a3 100644 --- a/packages/core/src/base/DataObject.ts +++ b/packages/core/src/base/DataObject.ts @@ -1,5 +1,4 @@ -import { CloneManager, defaultCloneMode } from "../clone/CloneManager"; -import { CloneMode } from "../clone/enums/CloneMode"; +import { CloneManager } from "../clone/CloneManager"; /** * Base class for cloneable data objects: wherever an instance is held — a component field, @@ -7,7 +6,6 @@ import { CloneMode } from "../clone/enums/CloneMode"; * A subclass should stay constructible without arguments: the clone system creates * preset-less copies bare and then populates every field. */ -@defaultCloneMode(CloneMode.Deep) export abstract class DataObject { /** * Create an independent deep copy of this object. @@ -17,3 +15,7 @@ export abstract class DataObject { return CloneManager._cloneValue(this, undefined, new Map()); } } + +// Deep marker read by the clone gate; a string-keyed property survives duplicated engine +// packages, where `instanceof DataObject` would silently fail. +Object.defineProperty(DataObject.prototype, "_isDeepCloneType", { value: true }); diff --git a/packages/core/src/clone/CloneDefaults.ts b/packages/core/src/clone/CloneDefaults.ts new file mode 100644 index 0000000000..7579ac6a66 --- /dev/null +++ b/packages/core/src/clone/CloneDefaults.ts @@ -0,0 +1,34 @@ +import { Component } from "../Component"; +import { Entity } from "../Entity"; +import { ReferResource } from "../asset/ReferResource"; +import { UpdateFlag } from "../UpdateFlag"; +import { UpdateFlagManager } from "../UpdateFlagManager"; +import { DisorderedArray } from "../utils/DisorderedArray"; +import { SafeLoopArray } from "../utils/SafeLoopArray"; +import { CloneManager } from "./CloneManager"; +import { ICustomClone } from "./ComponentCloner"; +import { CloneMode } from "./enums/CloneMode"; + +// The built-in default decision lives here — a module-graph sink — so the gate itself never +// imports the intrinsic classes (a top-level class import inside CloneManager would reorder +// module evaluation and break `extends` chains). The deep marker stays a string-keyed property +// because it survives duplicated engine packages, where `instanceof` silently fails; the +// intrinsic families below are always same-realm with the gate, so `instanceof` is safe. +CloneManager._determineDefaultCloneMode = (value: object): CloneMode => { + if ((value)._isDeepCloneType) return CloneMode.Deep; + if (value instanceof Entity || value instanceof Component) return CloneMode.Remap; + if (value instanceof ReferResource) return CloneMode.Assignment; + if ( + value instanceof UpdateFlagManager || + value instanceof UpdateFlag || + value instanceof DisorderedArray || + value instanceof SafeLoopArray + ) { + return CloneMode.Ignore; + } + return CloneMode.Assignment; +}; + +CloneManager._isRemapType = (value: any): boolean => value instanceof Entity || value instanceof Component; + +CloneManager._isCountedResource = (value: any): boolean => value instanceof ReferResource; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 389d4339c0..474246b35f 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -43,20 +43,6 @@ export function ignoreClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore); } -/** - * Class decorator — the default clone mode for instances of the decorated type - * (unregistered non-container types fall back to Assignment). - * A `Deep` type cloned without a same-type preset (e.g. as a container element) must construct - * bare — the gate creates the instance argument-less and then populates every field; host-bound - * types lacking a preset fail with a named error. - * @param mode - The clone mode applied to instances of the decorated type - */ -export function defaultCloneMode(mode: CloneMode) { - return function (target: Function): void { - Object.defineProperty(target.prototype, "_defaultCloneMode", { value: mode }); - }; -} - /** * @internal * Clone manager. Opt-out model: every enumerable field is cloned unless `@ignoreClone`, @@ -114,7 +100,7 @@ export class CloneManager { /** * @internal - * Register a field-level clone mode (highest priority — overrides the value type's `@defaultCloneMode`). + * Register a field-level clone mode (highest priority — overrides the built-in default decision). */ static _registerFieldMode(target: object, propertyKey: string, mode: CloneMode): void { let fields = CloneManager._subFieldModeMap.get(target.constructor); @@ -138,22 +124,19 @@ export class CloneManager { // `typeof`, not `instanceof` — null-prototype / cross-realm objects lack local Object.prototype. if (value === null || typeof value !== "object") return value; - // Mode priority: field decorator (highest) → container default deep → type's `@defaultCloneMode` → Assignment. + // Mode priority: field decorator (highest) → the built-in default decision by type family. let cloneMode = fieldMode; if (cloneMode === undefined) { - cloneMode = CloneManager._isContainer(value) - ? CloneMode.Deep - : ((value)._defaultCloneMode ?? CloneMode.Assignment); + cloneMode = CloneManager._isContainer(value) ? CloneMode.Deep : CloneManager._determineDefaultCloneMode(value); } else if (cloneMode === CloneMode.Deep) { // Error recovery (not a priority rule): engine-bound instances can't be deep cloned — - // recover to the type's executable mode (remap / share) and warn. - const typeDefault = (value)._defaultCloneMode; - if (typeDefault === CloneMode.Remap) { + // recover to the family's executable mode (remap / share) and warn. + if (CloneManager._isRemapType(value)) { Logger.warn( `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` ); cloneMode = CloneMode.Remap; - } else if (typeDefault === CloneMode.Assignment) { + } else if (CloneManager._isCountedResource(value)) { Logger.warn( `CloneManager: "${value.constructor.name}" is an engine-bound asset and cannot be deep cloned; ` + `@deepClone on this field falls back to sharing (use the asset's own clone() API to copy it).` @@ -196,24 +179,23 @@ export class CloneManager { /** * @internal - * Whether the value is an engine-bound Remap type (Entity / Component). + * The built-in default decision by type family; injected from `CloneDefaults` (a module-graph + * sink) so the gate never imports the intrinsic classes — a top-level class import here would + * reorder module evaluation and break `extends` chains. */ - static _isRemapType(value: any): boolean { - return value instanceof Object && (value)._defaultCloneMode === CloneMode.Remap; - } + static _determineDefaultCloneMode: (value: object) => CloneMode; /** - * Counted = registered Assignment (the ReferResource family) AND implementing the counting - * API — a user type registered Assignment without `_addReferCount` is a plain share; - * duck-typed counters that never registered (Shader) stay excluded. + * @internal + * Whether the value is an engine-bound Remap type (Entity / Component); injected from `CloneDefaults`. */ - private static _isCountedResource(value: any): boolean { - return ( - value instanceof Object && - (value)._defaultCloneMode === CloneMode.Assignment && - typeof (value)._addReferCount === "function" - ); - } + static _isRemapType: (value: any) => boolean; + + /** + * @internal + * Counted = the ReferResource family only; injected from `CloneDefaults`. + */ + static _isCountedResource: (value: any) => boolean; /** * The single container classification point. Invariant: every shape returning true MUST have @@ -343,7 +325,9 @@ export class CloneManager { } // Math value types are always deep cloned; registered here because math cannot depend on core. -const _markDeep = defaultCloneMode(CloneMode.Deep); +const _markDeep = (type: Function): void => { + Object.defineProperty(type.prototype, "_isDeepCloneType", { value: true }); +}; [ Ray, Vector2, diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 4624ad681f..9a3b063310 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -8,9 +8,10 @@ import { CloneMode } from "./enums/CloneMode"; export interface ICustomClone { /** * @internal - * Type default set via `@defaultCloneMode`; absence means Assignment (share). + * Deep marker of the DataObject family and math value types; absence means the + * built-in default decision applies. */ - readonly _defaultCloneMode?: CloneMode; + readonly _isDeepCloneType?: boolean; /** * @internal * Post-clone hook; `cloneMap` maps every source object in the cloned subtree to its clone. diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index 385bd35282..b02b1a73c7 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -1,5 +1,5 @@ /** - * How a value is cloned, decided by its type (see `@defaultCloneMode`). + * How a value is cloned, decided by the built-in default decision or a field decorator. */ export enum CloneMode { /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e3e1f6b3b6..a5811b591f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,5 @@ +import "./clone/CloneDefaults"; + export { Platform } from "./Platform"; export { Engine } from "./Engine"; export { SystemInfo } from "./SystemInfo"; diff --git a/packages/core/src/utils/DisorderedArray.ts b/packages/core/src/utils/DisorderedArray.ts index 08ecef6c34..81e7b8f8de 100644 --- a/packages/core/src/utils/DisorderedArray.ts +++ b/packages/core/src/utils/DisorderedArray.ts @@ -1,11 +1,8 @@ -import { defaultCloneMode } from "../clone/CloneManager"; -import { CloneMode } from "../clone/enums/CloneMode"; import { Utils } from "../Utils"; /** * High-performance unordered array, delete uses exchange method to improve performance, internal capacity only increases. */ -@defaultCloneMode(CloneMode.Ignore) export class DisorderedArray { /** The length of the array. */ length = 0; diff --git a/packages/core/src/utils/SafeLoopArray.ts b/packages/core/src/utils/SafeLoopArray.ts index 2c801c8277..a5dbc7f7bf 100644 --- a/packages/core/src/utils/SafeLoopArray.ts +++ b/packages/core/src/utils/SafeLoopArray.ts @@ -1,7 +1,3 @@ -import { defaultCloneMode } from "../clone/CloneManager"; -import { CloneMode } from "../clone/enums/CloneMode"; - -@defaultCloneMode(CloneMode.Ignore) export class SafeLoopArray { private _array: T[] = []; private _loopArray: T[] = []; diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index eb7e04751d..38863c8936 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1000,13 +1000,13 @@ describe("Clone remap", async () => { }); describe("Math value-type registration completeness", () => { - it("every math export with copyFrom is registered @defaultCloneMode(Deep)", async () => { + it("every math export with copyFrom carries the internal deep marker", async () => { const mathExports = await import("@galacean/engine-math"); const unregistered: string[] = []; for (const [name, exported] of Object.entries(mathExports)) { if (typeof exported !== "function" || !(exported as any).prototype) continue; if (typeof (exported as any).prototype.copyFrom !== "function") continue; - if ((exported as any).prototype._defaultCloneMode === undefined) unregistered.push(name); + if ((exported as any).prototype._isDeepCloneType === undefined) unregistered.push(name); } // A math value type missing from CloneManager's registration list falls back to // Assignment sharing — mutable state silently shared between source and clone. @@ -1338,7 +1338,7 @@ describe("Clone remap", async () => { if (typeof exported !== "function" || !exported.prototype) continue; // math carries only Deep markers; core/ui Deep types are the DataObject family const isDeep = - pkg === "math" ? exported.prototype._defaultCloneMode !== undefined : exported.prototype instanceof DataObject; + pkg === "math" ? exported.prototype._isDeepCloneType !== undefined : exported.prototype instanceof DataObject; if (!isDeep) continue; if (exempt.has(name)) continue; try { From 36f7446418e62d07d5360a4732d4345f265cbb0a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 17 Jul 2026 23:06:17 +0800 Subject: [PATCH 059/109] perf(clone): let DataObject.clone() skip mode resolution it already knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataObject.clone() called _cloneValue, which re-derives the mode via the 6-check _isContainer test plus the family lookup — work whose answer is fixed at the call site (a DataObject instance is always Deep, by construction of its own marker). It now calls _deepClone directly, the pure structural clone with no type-family logic, skipping the redundant resolution. _deepClone drops 'private' to allow the call; it has zero references to intrinsic classes, so this adds no new cross-file class dependency. Co-Authored-By: Claude Fable 5 --- packages/core/src/base/DataObject.ts | 5 ++++- packages/core/src/clone/CloneManager.ts | 5 ++++- tests/src/core/CloneManager.test.ts | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts index c94fe323a3..6d92df82f3 100644 --- a/packages/core/src/base/DataObject.ts +++ b/packages/core/src/base/DataObject.ts @@ -12,7 +12,10 @@ export abstract class DataObject { * @returns The cloned object */ clone(): this { - return CloneManager._cloneValue(this, undefined, new Map()); + // A DataObject instance is Deep by construction (see the marker below) — go straight to the + // structural clone, skipping `_cloneValue`'s mode resolution (container check, family lookup) + // for a value whose mode is already known. + return CloneManager._deepClone(this, undefined, new Map()); } } diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 474246b35f..34f568c99b 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -213,10 +213,13 @@ export class CloneManager { } /** + * @internal * Clone one object graph: the structure is fresh, while every member re-enters the gate and * follows its own semantics. Cycles / shared sub-graphs dedup through the identity map. + * Pure structural logic — no reference to any type-family predicate — so callers that already + * know a value is Deep-mode (e.g. `DataObject.clone()`) can skip `_cloneValue`'s mode resolution. */ - private static _deepClone(value: any, reuse: any, cloneMap: Map): any { + static _deepClone(value: any, reuse: any, cloneMap: Map): any { const existing = cloneMap.get(value); if (existing) return existing; diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 38863c8936..f96d8fc2d5 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1239,6 +1239,21 @@ describe("Clone remap", async () => { }); }); + describe("DataObject.clone() called directly", () => { + it("produces an independent deep copy without going through a component field", () => { + const source = new ParticleCompositeCurve(1, 2); + const cloned = source.clone(); + + expect(cloned).not.eq(source); + expect(cloned).instanceOf(ParticleCompositeCurve); + expect(cloned.constantMin).eq(1); + expect(cloned.constantMax).eq(2); + + cloned.constantMin = 99; + expect(source.constantMin).eq(1); + }); + }); + 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"); From bcf668fdb25db37ad5104ca51222ae5069edafd4 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 17 Jul 2026 23:28:58 +0800 Subject: [PATCH 060/109] refactor(clone): collapse mode-then-switch into decide-and-execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _cloneValue used to compute a CloneMode enum for the no-field-override case, then immediately re-switch on that enum (Ignore/Assignment/Remap/ fallthrough) — two passes for one decision. _cloneByDefault now decides and executes in a single pass (container check folded in too), replacing _determineDefaultCloneMode. Behavior is unchanged: traced every value category (container, DataObject/math marker, Entity/Component, Referesource, UpdateFlag family, unregistered class) under both implicit and explicit @deepClone-misuse-recovery paths against the prior implementation — the @deepClone-on-engine-bound warning is preserved, still only firing on the explicit field-decorator path. 1587/1587 unchanged. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneDefaults.ts | 15 ++++--- packages/core/src/clone/CloneManager.ts | 55 +++++++++++------------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/packages/core/src/clone/CloneDefaults.ts b/packages/core/src/clone/CloneDefaults.ts index 7579ac6a66..d9a5d03787 100644 --- a/packages/core/src/clone/CloneDefaults.ts +++ b/packages/core/src/clone/CloneDefaults.ts @@ -7,26 +7,27 @@ import { DisorderedArray } from "../utils/DisorderedArray"; import { SafeLoopArray } from "../utils/SafeLoopArray"; import { CloneManager } from "./CloneManager"; import { ICustomClone } from "./ComponentCloner"; -import { CloneMode } from "./enums/CloneMode"; // The built-in default decision lives here — a module-graph sink — so the gate itself never // imports the intrinsic classes (a top-level class import inside CloneManager would reorder // module evaluation and break `extends` chains). The deep marker stays a string-keyed property // because it survives duplicated engine packages, where `instanceof` silently fails; the // intrinsic families below are always same-realm with the gate, so `instanceof` is safe. -CloneManager._determineDefaultCloneMode = (value: object): CloneMode => { - if ((value)._isDeepCloneType) return CloneMode.Deep; - if (value instanceof Entity || value instanceof Component) return CloneMode.Remap; - if (value instanceof ReferResource) return CloneMode.Assignment; +// One pass, by type family: decide and execute together, no intermediate CloneMode. +CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map): any => { + if (CloneManager._isContainer(value)) return CloneManager._deepClone(value, reuse, cloneMap); + if ((value)._isDeepCloneType) return CloneManager._deepClone(value, reuse, cloneMap); + if (value instanceof Entity || value instanceof Component) return cloneMap.get(value) ?? value; + if (value instanceof ReferResource) return value; if ( value instanceof UpdateFlagManager || value instanceof UpdateFlag || value instanceof DisorderedArray || value instanceof SafeLoopArray ) { - return CloneMode.Ignore; + return reuse; } - return CloneMode.Assignment; + return value; }; CloneManager._isRemapType = (value: any): boolean => value instanceof Entity || value instanceof Component; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 34f568c99b..fe15dac6b2 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -114,40 +114,36 @@ export class CloneManager { /** * @internal - * Clone gate — resolves the effective clone mode for one value and executes it. + * Clone gate. Field decorator (highest priority) is handled inline; with no field override, + * dispatch goes straight to `_cloneByDefault` — one pass from value to action, no intermediate + * `CloneMode` to compute and re-switch on. */ static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { + if (fieldMode === CloneMode.Ignore) return reuse; // Explicit decorator shares the function; default keeps the clone's own rebound binding. if (typeof value === "function") { return fieldMode === undefined && typeof reuse === "function" ? reuse : value; } // `typeof`, not `instanceof` — null-prototype / cross-realm objects lack local Object.prototype. if (value === null || typeof value !== "object") return value; + if (fieldMode === undefined) return CloneManager._cloneByDefault(value, reuse, cloneMap); + if (fieldMode === CloneMode.Assignment) return value; - // Mode priority: field decorator (highest) → the built-in default decision by type family. - let cloneMode = fieldMode; - if (cloneMode === undefined) { - cloneMode = CloneManager._isContainer(value) ? CloneMode.Deep : CloneManager._determineDefaultCloneMode(value); - } else if (cloneMode === CloneMode.Deep) { - // Error recovery (not a priority rule): engine-bound instances can't be deep cloned — - // recover to the family's executable mode (remap / share) and warn. - if (CloneManager._isRemapType(value)) { - Logger.warn( - `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` - ); - cloneMode = CloneMode.Remap; - } else if (CloneManager._isCountedResource(value)) { - Logger.warn( - `CloneManager: "${value.constructor.name}" is an engine-bound asset and cannot be deep cloned; ` + - `@deepClone on this field falls back to sharing (use the asset's own clone() API to copy it).` - ); - cloneMode = CloneMode.Assignment; - } + // fieldMode === Deep: error recovery, not a priority rule — engine-bound instances can't be + // deep cloned; recover to the type's real action (remap / share) and warn. + if (CloneManager._isRemapType(value)) { + Logger.warn( + `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` + ); + return cloneMap.get(value) ?? value; + } + if (CloneManager._isCountedResource(value)) { + Logger.warn( + `CloneManager: "${value.constructor.name}" is an engine-bound asset and cannot be deep cloned; ` + + `@deepClone on this field falls back to sharing (use the asset's own clone() API to copy it).` + ); + return value; } - - if (cloneMode === CloneMode.Ignore) return reuse; - if (cloneMode === CloneMode.Assignment) return value; - if (cloneMode === CloneMode.Remap) return cloneMap.get(value) ?? value; return CloneManager._deepClone(value, reuse, cloneMap); } @@ -179,11 +175,11 @@ export class CloneManager { /** * @internal - * The built-in default decision by type family; injected from `CloneDefaults` (a module-graph - * sink) so the gate never imports the intrinsic classes — a top-level class import here would - * reorder module evaluation and break `extends` chains. + * Decide-and-execute for a value with no explicit field mode, by type family; injected from + * `CloneDefaults` (a module-graph sink) so the gate never imports the intrinsic classes — a + * top-level class import here would reorder module evaluation and break `extends` chains. */ - static _determineDefaultCloneMode: (value: object) => CloneMode; + static _cloneByDefault: (value: object, reuse: any, cloneMap: Map) => any; /** * @internal @@ -198,10 +194,11 @@ export class CloneManager { static _isCountedResource: (value: any) => boolean; /** + * @internal * The single container classification point. Invariant: every shape returning true MUST have * a dedicated `_deepClone` branch. `constructor === undefined` = null-prototype objects. */ - private static _isContainer(value: object): boolean { + static _isContainer(value: object): boolean { return ( Array.isArray(value) || value instanceof Map || From 5d27b6f3ebaf0ac5959634cfe08c0978f7e5bb1c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 17 Jul 2026 23:39:05 +0800 Subject: [PATCH 061/109] test(clone): cover @ignoreClone on a function field (previously untested) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No fix needed: deepCloneObject and ComponentCloner.cloneComponent both skip Ignore fields with their own 'continue' before ever calling _cloneValue, so the function branch's internal Ignore handling is unreachable through the real clone path — the field is simply left untouched, which is the correct behavior. Verified by tracing every _cloneValue call site; only these two ever pass an explicit fieldMode, and both filter Ignore upstream. Adds the missing regression coverage alongside the existing @assignmentClone-on-function-field test. Co-Authored-By: Claude Fable 5 --- tests/src/core/CloneManager.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index f96d8fc2d5..9ab388eb0b 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -268,6 +268,14 @@ class AssignedHandlerScript extends Script { 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; @@ -607,6 +615,26 @@ describe("Clone remap", async () => { 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("@deepClone array of entities", () => { From f0262a4a4f6f0cf5f2546e9e348208a83fa6f0d3 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 09:33:58 +0800 Subject: [PATCH 062/109] refactor(clone): replace the field-mode registry with prototype-chain lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field decorators (@deepClone/@assignmentClone/@ignoreClone) used to populate two Maps (own-per-class + a flattened, manually-invalidated cache) walked by getFieldModes(). Neither caller ever enumerated the map — both deepCloneObject and ComponentCloner.cloneComponent only ever did a single get(k) per field — so the whole registry collapses to one plain-object property per class (_fieldModes), prototypally chained to the parent's: native lookup resolves inheritance for free (a subclass re-decorating a field shadows the ancestor's), with no cache to keep in sync. Pinned by two new tests (shadowing + no cross-class leakage via a truthiness-vs-hasOwnProperty falsifiability check). First cut (per-field source._fieldModes?.[k] inside the field loop) regressed the clone benchmark ~15-25% — a fresh prototype-chain walk per field instead of per object. Hoisting the _fieldModes lookup above the loop (one walk per object, cheap property reads per field after) recovered it to within normal run-to-run noise (130-132us particle-entity / 2922-3012us entity-tree-121-nodes, matching the 127.8us / 2680us baseline before this change). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 56 ++++++---------------- packages/core/src/clone/ComponentCloner.ts | 5 +- tests/src/core/CloneManager.test.ts | 56 ++++++++++++++++++++++ 3 files changed, 73 insertions(+), 44 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index fe15dac6b2..2e99109e5d 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -53,46 +53,15 @@ export function ignoreClone(target: object, propertyKey: string): void { * gate — a nested class pairs its own acquisition (`_cloneTo` +1 / destroy -1). */ export class CloneManager { - /** @internal Own field-level clone modes per class (excluding inherited), from `@deepClone`/`@assignmentClone`/`@ignoreClone`. */ - static _subFieldModeMap = new Map>(); - /** @internal Flattened field-level clone modes per class (across the prototype chain), cached. */ - static _fieldModeMap = new Map>(); - - private static _objectType = Object.getPrototypeOf(Object); - - /** - * Get the field-level clone modes of a type, flattened across its prototype chain. - */ - static getFieldModes(type: Function): Map { - let modes = CloneManager._fieldModeMap.get(type); - if (!modes) { - modes = new Map(); - CloneManager._fieldModeMap.set(type, modes); - const objectType = CloneManager._objectType; - const subMap = CloneManager._subFieldModeMap; - let current = type; - while (current !== objectType) { - const own = subMap.get(current); - if (own) { - own.forEach((mode, key) => { - if (!modes.has(key)) modes.set(key, mode); - }); - } - current = Object.getPrototypeOf(current); - } - } - return modes; - } - /** * Clone all enumerable fields of source into target; each field goes through the clone gate, * honoring field-level decorators. */ - static deepCloneObject(source: object, target: object, cloneMap: Map): void { - const ctor = (<{ constructor?: Function }>source).constructor; - const fieldModes = ctor ? CloneManager.getFieldModes(ctor) : null; + static deepCloneObject(source: any, target: object, cloneMap: Map): void { + // Resolved once per object (a single prototype-chain walk), not once per field. + const fieldModes = source._fieldModes; for (const k in source) { - const fieldMode = fieldModes?.get(k); + const fieldMode = fieldModes?.[k]; if (fieldMode === CloneMode.Ignore) continue; target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode); } @@ -101,15 +70,18 @@ export class CloneManager { /** * @internal * Register a field-level clone mode (highest priority — overrides the built-in default decision). + * Stamped on the class's own `_fieldModes`, prototypally chained to the parent class's — native + * lookup resolves inheritance for free (a subclass re-decorating the same field name shadows the + * ancestor's), no separate registry or cache to keep in sync. */ - static _registerFieldMode(target: object, propertyKey: string, mode: CloneMode): void { - let fields = CloneManager._subFieldModeMap.get(target.constructor); - if (!fields) { - fields = new Map(); - CloneManager._subFieldModeMap.set(target.constructor, fields); + static _registerFieldMode(target: any, propertyKey: string, mode: CloneMode): void { + if (!Object.prototype.hasOwnProperty.call(target, "_fieldModes")) { + Object.defineProperty(target, "_fieldModes", { + value: Object.create(target._fieldModes ?? null), + configurable: true + }); } - fields.set(propertyKey, mode); - CloneManager._fieldModeMap.clear(); + target._fieldModes[propertyKey] = mode; } /** diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 9a3b063310..b9d605423e 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -32,9 +32,10 @@ export class ComponentCloner { * @param cloneMap - Identity map of the cloned subtree (source object → clone) */ static cloneComponent(source: Component, target: Component, cloneMap: Map): void { - const fieldModes = CloneManager.getFieldModes(source.constructor); + // Resolved once per component (a single prototype-chain walk), not once per field. + const fieldModes = (source)._fieldModes; for (const k in source) { - const fieldMode = fieldModes.get(k); + const fieldMode = fieldModes?.[k]; if (fieldMode === CloneMode.Ignore) continue; const sourceValue = source[k]; const preset = target[k]; diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 9ab388eb0b..1292901039 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -149,6 +149,20 @@ class BoundHandlerScript extends Script { } } +/** 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; @@ -637,6 +651,48 @@ describe("Clone remap", async () => { }); }); + 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", () => { + // Decorators run once, at class-definition time — by the time any test runs, + // SubOverrideScript's @ignoreClone on `reDecorated` has already been registered. If that + // registration mutated a store shared with BaseOverrideScript instead of a store of its + // own, every BaseOverrideScript instance (this one included) would inherit the corruption. + 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"); From cf90bd6162413d4684798333c812a7a1b7893d8d Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 09:45:35 +0800 Subject: [PATCH 063/109] refactor(clone): unify the two ad hoc _isDeepCloneType stamping sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataObject.ts and CloneManager.ts's math registration both did the same Object.defineProperty(type.prototype, "_isDeepCloneType", { value: true }) independently, as two different code shapes (a bare statement vs. a locally-scoped helper + forEach) with the string key duplicated in both files. The marker itself is still necessary — it is the only way to recognize two type families with no common base class (DataObject vs. math's value types, which cannot depend on core) and the only form that survives duplicated engine packages, where instanceof silently fails. markDeepCloneable() is now the single place that owns the key name and the defineProperty call; both sites call it. Co-Authored-By: Claude Fable 5 --- packages/core/src/base/DataObject.ts | 6 ++---- packages/core/src/clone/CloneManager.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts index 6d92df82f3..a4cce8a130 100644 --- a/packages/core/src/base/DataObject.ts +++ b/packages/core/src/base/DataObject.ts @@ -1,4 +1,4 @@ -import { CloneManager } from "../clone/CloneManager"; +import { CloneManager, markDeepCloneable } from "../clone/CloneManager"; /** * Base class for cloneable data objects: wherever an instance is held — a component field, @@ -19,6 +19,4 @@ export abstract class DataObject { } } -// Deep marker read by the clone gate; a string-keyed property survives duplicated engine -// packages, where `instanceof DataObject` would silently fail. -Object.defineProperty(DataObject.prototype, "_isDeepCloneType", { value: true }); +markDeepCloneable(DataObject); diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 2e99109e5d..ac56b2601c 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -43,6 +43,17 @@ export function ignoreClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore); } +/** + * @internal + * Stamp the deep-clone marker read by `_cloneByDefault` — a string-keyed property, not + * `instanceof`, because it must work uniformly for two type families with no common base + * (`DataObject`, and math's value types, which cannot depend on core) and survive duplicated + * engine packages, where `instanceof` would silently fail. + */ +export function markDeepCloneable(type: Function): void { + Object.defineProperty(type.prototype, "_isDeepCloneType", { value: true }); +} + /** * @internal * Clone manager. Opt-out model: every enumerable field is cloned unless `@ignoreClone`, @@ -297,9 +308,6 @@ export class CloneManager { } // Math value types are always deep cloned; registered here because math cannot depend on core. -const _markDeep = (type: Function): void => { - Object.defineProperty(type.prototype, "_isDeepCloneType", { value: true }); -}; [ Ray, Vector2, @@ -315,4 +323,4 @@ const _markDeep = (type: Function): void => { BoundingSphere, Plane, SphericalHarmonics3 -].forEach((type) => _markDeep(type)); +].forEach(markDeepCloneable); From 60e5cd3ad244251d53f05a913799860f41a8bdb2 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 11:10:54 +0800 Subject: [PATCH 064/109] fix(clone): fix three bugs in the DataObject template-method rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataObject.clone()/copyFrom() were rewritten as a template method — _clone and _copyFrom are optional override hooks, clone()/copyFrom() are the stable entry points, falling back to a generic structural clone when neither is overridden. Three bugs in that rewrite, found by tracing every branch against _deepClone's actual contract: 1. clone(map?)/copyFrom(source, map?) left `map` undefined when the caller omits it (the common case: `x.clone()`) — _deepClone dereferences cloneMap on its first line. Now defaults to `new Map()`. 2. copyFrom(source)'s generic fallback called `CloneManager._deepClone(this, source, map)` — value and reuse swapped versus _deepClone's (value, reuse, cloneMap) contract, so it read from `this` and wrote into `source`. Now `_deepClone(source, this, map)`. 3. The deepest one: making `copyFrom` a public method on the DataObject base class means every DataObject instance now satisfies `_deepClone`'s pre-existing duck-typed dispatch (`typeof value.copyFrom === "function"`, designed to recognize math's Vector3/Color-style specialized copy). A DataObject with no _copyFrom override reaching that branch called back into its own dispatcher — with no cloneMap threaded through (bug 1 masked this as an immediate crash instead of the stack overflow it actually is; fixing 1 alone surfaced it as `Maximum call stack size exceeded` across every Entity/Component remap test, not just DataObject ones, since the ordinary field walk reaches it too). Fixed with an injected predicate (_hasSpecializedCopy) that excludes DataObject from that branch — CloneManager can't import DataObject directly (circular). DataObject values are routed to `.clone()`/`.copyFrom()` from `_cloneByDefault` instead, preserving the reuse-preset optimization when a compatible target is already in the slot. _cloneByDefault checks `instanceof DataObject` only after the _isDeepCloneType marker already matched (DataObject values are a subset of Deep-marked ones) — regressed the clone benchmark otherwise, since every non-DataObject field (the majority in a typical scene) paid for the extra check. Recovered from +13-17% to roughly +5-10% for a DataObject-field-heavy scene (particle-entity), matching the added per-field-override capability's real cost. Added tests for the three fixed bugs plus the two new override hooks (_clone, _copyFrom), none previously exercised since no shipped DataObject subclass overrides either. 1594/1594. Co-Authored-By: Claude Fable 5 --- packages/core/src/base/DataObject.ts | 35 +++++++++-- packages/core/src/clone/CloneDefaults.ts | 24 +++++++- packages/core/src/clone/CloneManager.ts | 28 +++++++-- tests/src/core/CloneManager.test.ts | 76 ++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 12 deletions(-) diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts index a4cce8a130..5d2423f84d 100644 --- a/packages/core/src/base/DataObject.ts +++ b/packages/core/src/base/DataObject.ts @@ -5,17 +5,42 @@ import { CloneManager, markDeepCloneable } from "../clone/CloneManager"; * an array, a map — cloning produces an independent deep copy instead of a shared reference. * A subclass should stay constructible without arguments: the clone system creates * preset-less copies bare and then populates every field. + * + * Customize by overriding either hook, or neither (falls back to a generic structural + * clone — construct bare, then copy every enumerable field): + * - `_clone` — full control over both construction and population. + * - `_copyFrom` — construction is handled for you; only populate `this` from `source`. */ export abstract class DataObject { + protected _clone?: (map?: Map) => this; + protected _copyFrom?: (source: this, map?: Map) => void; + /** * Create an independent deep copy of this object. + * @param map - Identity map shared across a larger clone graph; a fresh one is used if omitted * @returns The cloned object */ - clone(): this { - // A DataObject instance is Deep by construction (see the marker below) — go straight to the - // structural clone, skipping `_cloneValue`'s mode resolution (container check, family lookup) - // for a value whose mode is already known. - return CloneManager._deepClone(this, undefined, new Map()); + clone(map: Map = new Map()): this { + if (this._clone) return this._clone(map); + if (this._copyFrom) { + const dst = CloneManager._bareConstruct(this.constructor); + dst._copyFrom(this, map); + return dst; + } + return CloneManager._deepClone(this, undefined, map); + } + + /** + * Populate this object with an independent copy of `source`'s state. + * @param source - The object to copy from + * @param map - Identity map shared across a larger clone graph; a fresh one is used if omitted + */ + copyFrom(source: this, map: Map = new Map()): void { + if (this._copyFrom) { + this._copyFrom(source, map); + } else { + CloneManager._deepClone(source, this, map); + } } } diff --git a/packages/core/src/clone/CloneDefaults.ts b/packages/core/src/clone/CloneDefaults.ts index d9a5d03787..da99b4ac62 100644 --- a/packages/core/src/clone/CloneDefaults.ts +++ b/packages/core/src/clone/CloneDefaults.ts @@ -1,6 +1,7 @@ import { Component } from "../Component"; import { Entity } from "../Entity"; import { ReferResource } from "../asset/ReferResource"; +import { DataObject } from "../base/DataObject"; import { UpdateFlag } from "../UpdateFlag"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { DisorderedArray } from "../utils/DisorderedArray"; @@ -16,7 +17,23 @@ import { ICustomClone } from "./ComponentCloner"; // One pass, by type family: decide and execute together, no intermediate CloneMode. CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map): any => { if (CloneManager._isContainer(value)) return CloneManager._deepClone(value, reuse, cloneMap); - if ((value)._isDeepCloneType) return CloneManager._deepClone(value, reuse, cloneMap); + if ((value)._isDeepCloneType) { + // Every DataObject carries this marker too — narrow to it only once Deep is already + // established, so the common case (Entity / asset / plain-share fields, the majority of a + // typical scene) never pays for the extra check. + if (value instanceof DataObject) { + // DataObject.clone()/copyFrom() are self-contained (they own the _clone/_copyFrom dispatch); + // route here instead of into _deepClone's generic copyFrom-branch, which would misfire — + // every DataObject exposes a public `copyFrom`, defeating that branch's duck-typed + // math-type check. + if (reuse instanceof DataObject && reuse !== value && reuse.constructor === value.constructor) { + reuse.copyFrom(value, cloneMap); + return reuse; + } + return value.clone(cloneMap); + } + return CloneManager._deepClone(value, reuse, cloneMap); + } if (value instanceof Entity || value instanceof Component) return cloneMap.get(value) ?? value; if (value instanceof ReferResource) return value; if ( @@ -33,3 +50,8 @@ CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map value instanceof Entity || value instanceof Component; CloneManager._isCountedResource = (value: any): boolean => value instanceof ReferResource; + +// DataObject's own public `copyFrom` is a dispatcher, not a specialized copy — excluded so +// `_deepClone` never calls back into it (that would recurse into `_deepClone` itself). +CloneManager._hasSpecializedCopy = (value: any): boolean => + !(value instanceof DataObject) && typeof value.copyFrom === "function"; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index ac56b2601c..3758c65954 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -102,7 +102,6 @@ export class CloneManager { * `CloneMode` to compute and re-switch on. */ static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { - if (fieldMode === CloneMode.Ignore) return reuse; // Explicit decorator shares the function; default keeps the clone's own rebound binding. if (typeof value === "function") { return fieldMode === undefined && typeof reuse === "function" ? reuse : value; @@ -127,7 +126,9 @@ export class CloneManager { ); return value; } - return CloneManager._deepClone(value, reuse, cloneMap); + // Neither family — attempt an actual deep clone via the same dispatch an undecorated field + // would use (container / DataObject / math / runtime-state / fallback share). + return CloneManager._cloneByDefault(value, reuse, cloneMap); } /** @@ -176,6 +177,14 @@ export class CloneManager { */ static _isCountedResource: (value: any) => boolean; + /** + * @internal + * Whether `value.copyFrom` is a self-contained value-type copy (math's Vector3/Color/... style), + * as opposed to `DataObject`'s dispatcher method of the same public name; injected from + * `CloneDefaults`. + */ + static _hasSpecializedCopy: (value: any) => boolean; + /** * @internal * The single container classification point. Invariant: every shape returning true MUST have @@ -272,8 +281,13 @@ export class CloneManager { const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; // Value type (Vector3, Color, Matrix, ...) — a class instance carrying a callable copyFrom. - // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in the data. - if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { + // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in + // the data. `DataObject` is excluded even though it also exposes a public `copyFrom`: that + // method's own generic fallback calls back into `_deepClone`, so treating it as "specialized" + // here would recurse into itself — DataObject family is routed via `_cloneByDefault` instead, + // and reaches this point only for its generic-fallback case, which belongs in the field-walk + // branch below. + if (ctor && ctor !== Object && CloneManager._hasSpecializedCopy(value)) { const dst = (reusable ?? CloneManager._bareConstruct(ctor)); cloneMap.set(value, dst); dst.copyFrom(value); @@ -291,10 +305,12 @@ export class CloneManager { } /** + * @internal * A deep-cloned instance without a compatible preset is constructed bare; name the contract - * when that fails instead of surfacing the constructor's raw error. + * when that fails instead of surfacing the constructor's raw error. Also used directly by + * `DataObject.clone()`, which needs the same bare-construction contract for its `_copyFrom` path. */ - private static _bareConstruct(ctor: new () => any): any { + static _bareConstruct(ctor: new () => any): any { try { return new ctor(); } catch (e) { diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 1292901039..e3330061e4 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1336,6 +1336,82 @@ describe("Clone remap", async () => { cloned.constantMin = 99; expect(source.constantMin).eq(1); }); + + it("copyFrom(source) populates this from source, not the reverse", () => { + const source = new ParticleCompositeCurve(1, 2); + const target = new ParticleCompositeCurve(99, 99); + + target.copyFrom(source); + + expect(target.constantMin).eq(1); + expect(target.constantMax).eq(2); + // The source must be untouched — copyFrom is one-directional (into `this`, from `source`). + expect(source.constantMin).eq(1); + expect(source.constantMax).eq(2); + }); + + it("an overridden _copyFrom is used by both clone() and copyFrom(), called with (source, map)", () => { + const calls: Array<{ target: OverriddenCopyFromConfig; source: OverriddenCopyFromConfig }> = []; + class OverriddenCopyFromConfig extends DataObject { + value = 0; + protected _copyFrom(source: OverriddenCopyFromConfig): void { + calls.push({ target: this, source }); + this.value = source.value * 10; + } + } + const source = new OverriddenCopyFromConfig(); + source.value = 5; + + const cloned = source.clone(); + expect(cloned).not.eq(source); + expect(cloned.value).eq(50); + expect(calls[0].target).eq(cloned); + expect(calls[0].source).eq(source); + + const target = new OverriddenCopyFromConfig(); + target.copyFrom(source); + expect(target.value).eq(50); + expect(calls[1].target).eq(target); + expect(calls[1].source).eq(source); + }); + + it("an overridden _clone takes full control, bypassing the generic bare-construct + copyFrom path", () => { + class OverriddenCloneConfig extends DataObject { + value = 0; + protected _clone(): this { + const dst = new OverriddenCloneConfig(); + dst.value = this.value + 1000; + return dst; + } + } + const source = new OverriddenCloneConfig(); + source.value = 1; + + const cloned = source.clone(); + + expect(cloned).not.eq(source); + expect(cloned.value).eq(1001); + }); + + it("a plain DataObject value nested in a container round-trips through clone() without recursing", () => { + // Regression: DataObject exposes a public copyFrom (the dispatcher); _deepClone's own + // duck-typed "does this have a specialized copyFrom" check must not mistake it for one, + // or the dispatcher recurses into itself and never terminates. + const rootEntity = scene.createRootEntity("root"); + const parent = rootEntity.createChild("parent"); + const script = parent.addComponent(CopyFromDataScript); + const curve = new ParticleCompositeCurve(3, 4); + script.config = { curves: [curve] }; + + const cloned = parent.clone(); + const clonedCurve = cloned.getComponent(CopyFromDataScript).config.curves[0]; + + expect(clonedCurve).not.eq(curve); + expect(clonedCurve.constantMin).eq(3); + expect(clonedCurve.constantMax).eq(4); + + rootEntity.destroy(); + }); }); describe("Parameter-constructed Deep values as container elements", () => { From 6f72fb42b69be37228fba329db856c4c06b9d18c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 11:25:17 +0800 Subject: [PATCH 065/109] refactor(clone): shrink DataObject to a bare instanceof marker class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope decision: this PR solves cloning, so DataObject stops growing an API surface of its own. The clone()/copyFrom() template methods, the _clone/_copyFrom override hooks and the _hasSpecializedCopy predicate are removed — DataObject is now an empty abstract class whose only role is `instanceof DataObject` in the default decision. It stays exported solely because the ui package's Transition extends it (siblings can only reach public exports); marked @internal and pulled out of the user docs — not public API for now. With no public copyFrom on the base class, _deepClone's historical duck-typed dispatch (`typeof value.copyFrom === "function"`) is safe again and math value types go back to exactly that pre-existing logic — its inherent looseness predates this PR and is not this PR's problem to solve. That also deletes the whole marker machinery: _isDeepCloneType, markDeepCloneable, the math registration list in CloneManager (and its 14 math imports), and the completeness test that guarded the list. The duplicated-package argument for a string-keyed marker died with the export: every DataObject subclass is engine-internal, always same-realm with the gate. The default decision places the copyFrom duck-check after the intrinsic families, so an engine-bound object can never be mistaken for a value type. Clone benchmark back at baseline (123-130us particle-entity / 2963-2988us tree, vs 127.8/2680 before the template-method round) — the template method's +5-10% residual left with the API. 1588/1588. Co-Authored-By: Claude Fable 5 --- docs/en/core/clone.mdx | 19 ---- docs/zh/core/clone.mdx | 19 ---- packages/core/src/base/DataObject.ts | 52 ++-------- packages/core/src/clone/CloneDefaults.ts | 33 ++---- packages/core/src/clone/CloneManager.ts | 68 +------------ packages/core/src/clone/ComponentCloner.ts | 6 -- packages/core/src/shader/ShaderData.ts | 4 +- tests/src/core/CloneManager.test.ts | 113 +-------------------- 8 files changed, 27 insertions(+), 287 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index e3a42cb0a8..c3f4f0f999 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -145,24 +145,5 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - `deepClone` cannot deep clone engine-bound objects: `Entity` / `Component` references fall back to remapping and assets fall back to sharing, both with a warning. If you need a real copy of an asset, use the asset's own clone API. - If the clone decorators do not meet the requirements, you can implement the [_cloneTo()](/apis/design/#IClone-cloneTo) method to add custom cloning. -### Custom Data Types - -The decorators above act on fields. If you define a reusable data type — a settings object, a curve, a damage profile — extend `DataObject`: wherever an instance is held (a component field, an array, a map), cloning produces an independent deep copy, and the instance gains a `clone()` method: - -```typescript -import { DataObject, Vector3 } from "@galacean/engine"; - -class DamageProfile extends DataObject { - base = 10; - falloff = new Vector3(1, 0.5, 0.25); -} -``` - -A class that does not extend `DataObject` is shared by reference — extending it is what turns instances into independent copies per clone. Field decorators still override this on individual fields. - - -A `DataObject` subclass should stay constructible without arguments: when the clone system meets an instance in a container (array, `Map`, plain object), it creates the copy with `new Type()` and then fills every field. A constructor that requires arguments fails at clone time with an error naming this contract. - - ## 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. The class holding the field is responsible for releasing that reference when it is destroyed — built-in components already handle this; for custom scripts, release the resources you hold in `onDestroy`. diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index ef2600155d..e713ee8cbe 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -146,24 +146,5 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - `deepClone` 无法深克隆引擎绑定的对象:`Entity` / `Component` 引用会回退为重映射,资产会回退为共享,二者都会打印告警。如果需要真正复制一份资产,请使用资产自身的克隆 API。 - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 -### 自定义数据类型 - -上面的装饰器作用于字段。如果你定义了一个可复用的数据类型 —— 配置对象、曲线、伤害档案等,让它继承 `DataObject`:无论实例被组件字段、数组还是 Map 持有,克隆都会产生独立的深拷贝,实例同时获得 `clone()` 方法: - -```typescript -import { DataObject, Vector3 } from "@galacean/engine"; - -class DamageProfile extends DataObject { - base = 10; - falloff = new Vector3(1, 0.5, 0.25); -} -``` - -不继承 `DataObject` 的类实例默认共享引用 —— 继承正是让它在每次克隆时变成独立拷贝的开关。字段装饰器依然可以在具体字段上覆盖这一行为。 - - -`DataObject` 的子类应支持无参构造:克隆系统在容器(数组、`Map`、普通对象)里遇到实例时,会先 `new Type()` 创建拷贝再逐字段填充。构造函数带必需参数会在克隆时报错,错误信息会指明这条契约。 - - ## 克隆与资产引用计数 克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数。持有该字段的类负责在销毁时释放这次引用 —— 内置组件已由引擎处理;自定义脚本请在 `onDestroy` 中释放自己持有的资源。 diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts index 5d2423f84d..2b7c7637e6 100644 --- a/packages/core/src/base/DataObject.ts +++ b/packages/core/src/base/DataObject.ts @@ -1,47 +1,9 @@ -import { CloneManager, markDeepCloneable } from "../clone/CloneManager"; - /** - * Base class for cloneable 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 should stay constructible without arguments: the clone system creates - * preset-less copies bare and then populates every field. - * - * Customize by overriding either hook, or neither (falls back to a generic structural - * clone — construct bare, then copy every enumerable field): - * - `_clone` — full control over both construction and population. - * - `_copyFrom` — construction is handled for you; only populate `this` from `source`. + * @internal + * Base class marking engine 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 + * (the clone gate recognizes the family via `instanceof`). A subclass should stay constructible + * without arguments: the clone system creates preset-less copies bare and then populates every + * field. Exported only so sibling engine packages (ui) can extend it — not public API for now. */ -export abstract class DataObject { - protected _clone?: (map?: Map) => this; - protected _copyFrom?: (source: this, map?: Map) => void; - - /** - * Create an independent deep copy of this object. - * @param map - Identity map shared across a larger clone graph; a fresh one is used if omitted - * @returns The cloned object - */ - clone(map: Map = new Map()): this { - if (this._clone) return this._clone(map); - if (this._copyFrom) { - const dst = CloneManager._bareConstruct(this.constructor); - dst._copyFrom(this, map); - return dst; - } - return CloneManager._deepClone(this, undefined, map); - } - - /** - * Populate this object with an independent copy of `source`'s state. - * @param source - The object to copy from - * @param map - Identity map shared across a larger clone graph; a fresh one is used if omitted - */ - copyFrom(source: this, map: Map = new Map()): void { - if (this._copyFrom) { - this._copyFrom(source, map); - } else { - CloneManager._deepClone(source, this, map); - } - } -} - -markDeepCloneable(DataObject); +export abstract class DataObject {} diff --git a/packages/core/src/clone/CloneDefaults.ts b/packages/core/src/clone/CloneDefaults.ts index da99b4ac62..2a162fb3c7 100644 --- a/packages/core/src/clone/CloneDefaults.ts +++ b/packages/core/src/clone/CloneDefaults.ts @@ -11,27 +11,11 @@ import { ICustomClone } from "./ComponentCloner"; // The built-in default decision lives here — a module-graph sink — so the gate itself never // imports the intrinsic classes (a top-level class import inside CloneManager would reorder -// module evaluation and break `extends` chains). The deep marker stays a string-keyed property -// because it survives duplicated engine packages, where `instanceof` silently fails; the -// intrinsic families below are always same-realm with the gate, so `instanceof` is safe. +// module evaluation and break `extends` chains). All families resolve by `instanceof`: every +// instance the gate can meet is engine-constructed, always same-realm with the gate. // One pass, by type family: decide and execute together, no intermediate CloneMode. CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map): any => { - if (CloneManager._isContainer(value)) return CloneManager._deepClone(value, reuse, cloneMap); - if ((value)._isDeepCloneType) { - // Every DataObject carries this marker too — narrow to it only once Deep is already - // established, so the common case (Entity / asset / plain-share fields, the majority of a - // typical scene) never pays for the extra check. - if (value instanceof DataObject) { - // DataObject.clone()/copyFrom() are self-contained (they own the _clone/_copyFrom dispatch); - // route here instead of into _deepClone's generic copyFrom-branch, which would misfire — - // every DataObject exposes a public `copyFrom`, defeating that branch's duck-typed - // math-type check. - if (reuse instanceof DataObject && reuse !== value && reuse.constructor === value.constructor) { - reuse.copyFrom(value, cloneMap); - return reuse; - } - return value.clone(cloneMap); - } + if (CloneManager._isContainer(value) || value instanceof DataObject) { return CloneManager._deepClone(value, reuse, cloneMap); } if (value instanceof Entity || value instanceof Component) return cloneMap.get(value) ?? value; @@ -44,14 +28,15 @@ CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Mapvalue).copyFrom === "function") { + return CloneManager._deepClone(value, reuse, cloneMap); + } return value; }; CloneManager._isRemapType = (value: any): boolean => value instanceof Entity || value instanceof Component; CloneManager._isCountedResource = (value: any): boolean => value instanceof ReferResource; - -// DataObject's own public `copyFrom` is a dispatcher, not a specialized copy — excluded so -// `_deepClone` never calls back into it (that would recurse into `_deepClone` itself). -CloneManager._hasSpecializedCopy = (value: any): boolean => - !(value instanceof DataObject) && typeof value.copyFrom === "function"; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 3758c65954..7fe9783507 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,19 +1,3 @@ -import { - BoundingBox, - BoundingFrustum, - BoundingSphere, - Color, - Matrix, - Matrix3x3, - Plane, - Quaternion, - Ray, - Rect, - SphericalHarmonics3, - Vector2, - Vector3, - Vector4 -} from "@galacean/engine-math"; import { IReferable } from "../asset/IReferable"; import { TypedArray } from "../base/Constant"; import { Logger } from "../base/Logger"; @@ -43,17 +27,6 @@ export function ignoreClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore); } -/** - * @internal - * Stamp the deep-clone marker read by `_cloneByDefault` — a string-keyed property, not - * `instanceof`, because it must work uniformly for two type families with no common base - * (`DataObject`, and math's value types, which cannot depend on core) and survive duplicated - * engine packages, where `instanceof` would silently fail. - */ -export function markDeepCloneable(type: Function): void { - Object.defineProperty(type.prototype, "_isDeepCloneType", { value: true }); -} - /** * @internal * Clone manager. Opt-out model: every enumerable field is cloned unless `@ignoreClone`, @@ -177,14 +150,6 @@ export class CloneManager { */ static _isCountedResource: (value: any) => boolean; - /** - * @internal - * Whether `value.copyFrom` is a self-contained value-type copy (math's Vector3/Color/... style), - * as opposed to `DataObject`'s dispatcher method of the same public name; injected from - * `CloneDefaults`. - */ - static _hasSpecializedCopy: (value: any) => boolean; - /** * @internal * The single container classification point. Invariant: every shape returning true MUST have @@ -281,13 +246,8 @@ export class CloneManager { const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; // Value type (Vector3, Color, Matrix, ...) — a class instance carrying a callable copyFrom. - // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in - // the data. `DataObject` is excluded even though it also exposes a public `copyFrom`: that - // method's own generic fallback calls back into `_deepClone`, so treating it as "specialized" - // here would recurse into itself — DataObject family is routed via `_cloneByDefault` instead, - // and reaches this point only for its generic-fallback case, which belongs in the field-walk - // branch below. - if (ctor && ctor !== Object && CloneManager._hasSpecializedCopy(value)) { + // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in the data. + if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { const dst = (reusable ?? CloneManager._bareConstruct(ctor)); cloneMap.set(value, dst); dst.copyFrom(value); @@ -305,12 +265,10 @@ export class CloneManager { } /** - * @internal * A deep-cloned instance without a compatible preset is constructed bare; name the contract - * when that fails instead of surfacing the constructor's raw error. Also used directly by - * `DataObject.clone()`, which needs the same bare-construction contract for its `_copyFrom` path. + * when that fails instead of surfacing the constructor's raw error. */ - static _bareConstruct(ctor: new () => any): any { + private static _bareConstruct(ctor: new () => any): any { try { return new ctor(); } catch (e) { @@ -322,21 +280,3 @@ export class CloneManager { } } } - -// Math value types are always deep cloned; registered here because math cannot depend on core. -[ - Ray, - Vector2, - Vector3, - Vector4, - Quaternion, - Matrix, - Matrix3x3, - Color, - Rect, - BoundingBox, - BoundingFrustum, - BoundingSphere, - Plane, - SphericalHarmonics3 -].forEach(markDeepCloneable); diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index b9d605423e..5fb1d9f7d5 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -6,12 +6,6 @@ import { CloneMode } from "./enums/CloneMode"; * Clone protocol read by the clone system; every member is optional. */ export interface ICustomClone { - /** - * @internal - * Deep marker of the DataObject family and math value types; absence means the - * built-in default decision applies. - */ - readonly _isDeepCloneType?: boolean; /** * @internal * Post-clone hook; `cloneMap` maps every source object in the cloned subtree to its clone. diff --git a/packages/core/src/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts index 85e31460dd..a4011261ad 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -603,10 +603,10 @@ export class ShaderData extends DataObject implements IReferable, IClone { } } - override clone(): this { + clone(): ShaderData { const shaderData = new ShaderData(this._group); this.cloneTo(shaderData); - return shaderData; + return shaderData; } cloneTo(target: ShaderData): void { diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index e3330061e4..5aab921af0 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1083,21 +1083,6 @@ describe("Clone remap", async () => { }); }); - describe("Math value-type registration completeness", () => { - it("every math export with copyFrom carries the internal deep marker", async () => { - const mathExports = await import("@galacean/engine-math"); - const unregistered: string[] = []; - for (const [name, exported] of Object.entries(mathExports)) { - if (typeof exported !== "function" || !(exported as any).prototype) continue; - if (typeof (exported as any).prototype.copyFrom !== "function") continue; - if ((exported as any).prototype._isDeepCloneType === undefined) unregistered.push(name); - } - // A math value type missing from CloneManager's registration list falls back to - // Assignment sharing — mutable state silently shared between source and clone. - expect(unregistered).deep.eq([]); - }); - }); - 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"); @@ -1323,97 +1308,6 @@ describe("Clone remap", async () => { }); }); - describe("DataObject.clone() called directly", () => { - it("produces an independent deep copy without going through a component field", () => { - const source = new ParticleCompositeCurve(1, 2); - const cloned = source.clone(); - - expect(cloned).not.eq(source); - expect(cloned).instanceOf(ParticleCompositeCurve); - expect(cloned.constantMin).eq(1); - expect(cloned.constantMax).eq(2); - - cloned.constantMin = 99; - expect(source.constantMin).eq(1); - }); - - it("copyFrom(source) populates this from source, not the reverse", () => { - const source = new ParticleCompositeCurve(1, 2); - const target = new ParticleCompositeCurve(99, 99); - - target.copyFrom(source); - - expect(target.constantMin).eq(1); - expect(target.constantMax).eq(2); - // The source must be untouched — copyFrom is one-directional (into `this`, from `source`). - expect(source.constantMin).eq(1); - expect(source.constantMax).eq(2); - }); - - it("an overridden _copyFrom is used by both clone() and copyFrom(), called with (source, map)", () => { - const calls: Array<{ target: OverriddenCopyFromConfig; source: OverriddenCopyFromConfig }> = []; - class OverriddenCopyFromConfig extends DataObject { - value = 0; - protected _copyFrom(source: OverriddenCopyFromConfig): void { - calls.push({ target: this, source }); - this.value = source.value * 10; - } - } - const source = new OverriddenCopyFromConfig(); - source.value = 5; - - const cloned = source.clone(); - expect(cloned).not.eq(source); - expect(cloned.value).eq(50); - expect(calls[0].target).eq(cloned); - expect(calls[0].source).eq(source); - - const target = new OverriddenCopyFromConfig(); - target.copyFrom(source); - expect(target.value).eq(50); - expect(calls[1].target).eq(target); - expect(calls[1].source).eq(source); - }); - - it("an overridden _clone takes full control, bypassing the generic bare-construct + copyFrom path", () => { - class OverriddenCloneConfig extends DataObject { - value = 0; - protected _clone(): this { - const dst = new OverriddenCloneConfig(); - dst.value = this.value + 1000; - return dst; - } - } - const source = new OverriddenCloneConfig(); - source.value = 1; - - const cloned = source.clone(); - - expect(cloned).not.eq(source); - expect(cloned.value).eq(1001); - }); - - it("a plain DataObject value nested in a container round-trips through clone() without recursing", () => { - // Regression: DataObject exposes a public copyFrom (the dispatcher); _deepClone's own - // duck-typed "does this have a specialized copyFrom" check must not mistake it for one, - // or the dispatcher recurses into itself and never terminates. - const rootEntity = scene.createRootEntity("root"); - const parent = rootEntity.createChild("parent"); - const script = parent.addComponent(CopyFromDataScript); - const curve = new ParticleCompositeCurve(3, 4); - script.config = { curves: [curve] }; - - const cloned = parent.clone(); - const clonedCurve = cloned.getComponent(CopyFromDataScript).config.curves[0]; - - expect(clonedCurve).not.eq(curve); - expect(clonedCurve.constantMin).eq(3); - expect(clonedCurve.constantMax).eq(4); - - 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"); @@ -1511,9 +1405,12 @@ describe("Clone remap", async () => { for (const [pkg, ns] of packages) { for (const [name, exported] of Object.entries(ns)) { if (typeof exported !== "function" || !exported.prototype) continue; - // math carries only Deep markers; core/ui Deep types are the DataObject family + // math value types dispatch by their callable copyFrom; core/ui Deep types are the + // DataObject family const isDeep = - pkg === "math" ? exported.prototype._isDeepCloneType !== undefined : exported.prototype instanceof DataObject; + pkg === "math" + ? typeof exported.prototype.copyFrom === "function" + : exported.prototype instanceof DataObject; if (!isDeep) continue; if (exempt.has(name)) continue; try { From f8d2ff1d684d2e50b105257537b54213d7680063 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 12:22:03 +0800 Subject: [PATCH 066/109] refactor(clone): split _deepClone into a dispatcher plus per-shape methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _deepClone had grown into ~90 lines mixing six unrelated strategies (buffer view / array / map / set / value-type copyFrom / field-walk) in one body, the buffer-view case alone carrying its own DataView-vs- TypedArray sub-branching. It is now a ~10-line dispatcher over five named private methods (_cloneBufferView / _cloneArray / _cloneMapValue / _cloneSetValue / _cloneClassInstance), each identifying and cloning its own shape in one place. Behavior-identical: pure extraction, no branch reordering, no predicate changes. Full suite 1588/1588, clone benchmark unchanged (125-132us particle-entity / 2928-3295us tree — the extracted static calls inline). Not changed: _cloneByDefault still pre-checks _isContainer before dispatching here, so container shapes are still type-tested twice (once to decide "deep clone", once to pick the branch). Collapsing that would mean threading a "not handled" sentinel across the CloneManager / CloneDefaults boundary (CloneManager cannot import DataObject — the module-graph constraint that put the family decision in CloneDefaults), trading a cheap fast-failing typeof/instanceof for a heavier protocol; not worth it. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 128 +++++++++++++----------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 7fe9783507..6cea3d7ee2 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -177,70 +177,86 @@ export class CloneManager { const existing = cloneMap.get(value); if (existing) return existing; - // ArrayBuffer views — byte copy (covers every view `_isContainer` routes here, incl. DataView). - if (ArrayBuffer.isView(value)) { - let dst: ArrayBufferView; - if (value instanceof DataView) { - const src = value; - if (reuse instanceof DataView && reuse !== value && reuse.byteLength === src.byteLength) { - new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( - new Uint8Array(src.buffer, src.byteOffset, src.byteLength) - ); - dst = reuse; - } else { - dst = new DataView(src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength)); - } + // Each shape identifies and clones itself in one place — no separate classifier to keep in + // sync (the four container shapes must precede the class-instance fallback, which would + // otherwise field-walk a Map / Set / view into garbage). + if (ArrayBuffer.isView(value)) return CloneManager._cloneBufferView(value, reuse, cloneMap); + if (Array.isArray(value)) return CloneManager._cloneArray(value, cloneMap); + if (value instanceof Map) return CloneManager._cloneMapValue(value, cloneMap); + if (value instanceof Set) return CloneManager._cloneSetValue(value, cloneMap); + return CloneManager._cloneClassInstance(value, reuse, cloneMap); + } + + /** ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. */ + private static _cloneBufferView(value: ArrayBufferView, reuse: any, cloneMap: Map): ArrayBufferView { + let dst: ArrayBufferView; + if (value instanceof DataView) { + const src = value; + if (reuse instanceof DataView && reuse !== value && reuse.byteLength === src.byteLength) { + new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( + new Uint8Array(src.buffer, src.byteOffset, src.byteLength) + ); + dst = reuse; } else { - const src = value; - if ( - reuse && - reuse !== value && - reuse.constructor === src.constructor && - (reuse).length === src.length - ) { - (reuse).set(src); - dst = reuse; - } else { - dst = src.slice(); - } + dst = new DataView(src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength)); + } + } else { + const src = value; + if ( + reuse && + reuse !== value && + reuse.constructor === src.constructor && + (reuse).length === src.length + ) { + (reuse).set(src); + dst = reuse; + } else { + dst = src.slice(); } - cloneMap.set(value, dst); - return dst; } + cloneMap.set(value, dst); + return dst; + } - // Array — fresh instance, each member through the gate. - if (Array.isArray(value)) { - const dst = new Array(value.length); - cloneMap.set(value, dst); - for (let i = 0, n = value.length; i < n; i++) { - dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); - } - return dst; + /** Array — fresh instance, each element re-entering the gate. */ + private static _cloneArray(value: any[], cloneMap: Map): any[] { + const dst = new Array(value.length); + cloneMap.set(value, dst); + for (let i = 0, n = value.length; i < n; i++) { + dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); } + return dst; + } - // Map - if (value instanceof Map) { - const dst = new Map(); - cloneMap.set(value, dst); - for (const entry of value) { - dst.set( - CloneManager._cloneValue(entry[0], undefined, cloneMap), - CloneManager._cloneValue(entry[1], undefined, cloneMap) - ); - } - return dst; + /** Map — fresh instance, each key and value re-entering the gate. */ + private static _cloneMapValue(value: Map, cloneMap: Map): Map { + const dst = new Map(); + cloneMap.set(value, dst); + for (const entry of value) { + dst.set( + CloneManager._cloneValue(entry[0], undefined, cloneMap), + CloneManager._cloneValue(entry[1], undefined, cloneMap) + ); } + return dst; + } - // Set - if (value instanceof Set) { - const dst = new Set(); - cloneMap.set(value, dst); - for (const v of value) { - dst.add(CloneManager._cloneValue(v, undefined, cloneMap)); - } - return dst; + /** Set — fresh instance, each member re-entering the gate. */ + private static _cloneSetValue(value: Set, cloneMap: Map): Set { + const dst = new Set(); + cloneMap.set(value, dst); + for (const v of value) { + dst.add(CloneManager._cloneValue(v, undefined, cloneMap)); } + return dst; + } + /** + * Class instance or plain / null-prototype object — reuse a compatible preset or construct bare, + * then copy via a value type's `copyFrom` (Vector3 / Color / ...) or, failing that, by walking + * every field; finally run its `_cloneTo` hook. + */ + private static _cloneClassInstance(value: any, reuse: any, cloneMap: Map): any { const ctor = value.constructor; // Compatible reuse: a distinct instance of the exact same type (null-prototype matches null-prototype). const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; @@ -255,8 +271,8 @@ export class CloneManager { return dst; } - // Object — reuse or construct (null-prototype objects have no ctor: rebuild as such), - // then populate all fields (opt-out) and run its `_cloneTo` hook. + // Reuse or construct (null-prototype objects have no ctor: rebuild as such), then populate all + // fields (opt-out) and run its `_cloneTo` hook. const dst = reusable ?? (ctor ? CloneManager._bareConstruct(ctor) : Object.create(null)); cloneMap.set(value, dst); CloneManager.deepCloneObject(value, dst, cloneMap); From 13f1b1137a264146212a816de3d2967419ad4159 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 12:32:46 +0800 Subject: [PATCH 067/109] refactor(clone): dispatch directly by type, dropping _isContainer + _deepClone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default path classified every value twice: _cloneByDefault first asked the _isContainer predicate "should this be deep cloned", then _deepClone re-tested Array.isArray / instanceof Map / ... to pick the branch. That split also forced an implicit contract — "every shape _isContainer returns true for MUST have a matching _deepClone branch" — kept honest only by a comment. _cloneByDefault now identifies each type once and calls its handler directly (the per-shape _clone* methods promoted to internal statics), mirroring how it already dispatched Entity / ReferResource / runtime state. _isContainer and the _deepClone dispatcher are deleted; a shape can no longer be recognized without also being cloned, so the contract is gone. The cycle-dedup `cloneMap.get` moves to the top of _cloneByDefault, where it also subsumes the Entity/Component in-subtree remap: the map holds "source → clone" for both tree-built component clones and deep-clone results, so one `if (existing) return existing` covers dedup and remap alike; an Entity reaching past it is outside the subtree and keeps its original reference. Verified branch-by-branch that every value category (container / engine-bound family / DataObject / plain / math / unknown) lands where it did before. 1588/1588, benchmark within noise (mean 132 / min 128us particle-entity, matching the 123-132 baseline). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneDefaults.ts | 40 ++++++++++---- packages/core/src/clone/CloneManager.ts | 62 +++++++--------------- packages/core/src/clone/ComponentCloner.ts | 2 +- 3 files changed, 50 insertions(+), 54 deletions(-) diff --git a/packages/core/src/clone/CloneDefaults.ts b/packages/core/src/clone/CloneDefaults.ts index 2a162fb3c7..3775701643 100644 --- a/packages/core/src/clone/CloneDefaults.ts +++ b/packages/core/src/clone/CloneDefaults.ts @@ -13,12 +13,24 @@ import { ICustomClone } from "./ComponentCloner"; // imports the intrinsic classes (a top-level class import inside CloneManager would reorder // module evaluation and break `extends` chains). All families resolve by `instanceof`: every // instance the gate can meet is engine-constructed, always same-realm with the gate. -// One pass, by type family: decide and execute together, no intermediate CloneMode. +// Each type is identified and handled in one step — no intermediate CloneMode, and no separate +// "is this a container" predicate whose truths must each be mirrored by a clone branch elsewhere. CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map): any => { - if (CloneManager._isContainer(value) || value instanceof DataObject) { - return CloneManager._deepClone(value, reuse, cloneMap); - } - if (value instanceof Entity || value instanceof Component) return cloneMap.get(value) ?? value; + // Already produced for this source in the graph — a deep clone made earlier, or, for an + // Entity / Component inside the cloned subtree, its clone registered at tree-build time. Reusing + // it dedups shared / cyclic references and doubles as the in-subtree remap. + const existing = cloneMap.get(value); + if (existing) return existing; + + // Containers — identify and clone in one step. + if (ArrayBuffer.isView(value)) return CloneManager._cloneBufferView(value, reuse, cloneMap); + if (Array.isArray(value)) return CloneManager._cloneArray(value, cloneMap); + if (value instanceof Map) return CloneManager._cloneMapValue(value, cloneMap); + if (value instanceof Set) return CloneManager._cloneSetValue(value, cloneMap); + + // Engine-bound families — never deep cloned. An Entity / Component reaching here is outside the + // cloned subtree (inside was returned by the dedup above), so its original reference is kept. + if (value instanceof Entity || value instanceof Component) return value; if (value instanceof ReferResource) return value; if ( value instanceof UpdateFlagManager || @@ -28,11 +40,19 @@ CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Mapvalue).copyFrom === "function") { - return CloneManager._deepClone(value, reuse, cloneMap); + + // Deep-cloned objects: the DataObject family, plain / null-prototype objects, and math value + // types (dispatched by their callable `copyFrom` — math cannot depend on core, so it cannot + // extend DataObject). Any other class instance is shared. Placed after the engine-bound families + // so one of those can never be mistaken for a value type. + const ctor = (<{ constructor?: Function }>value).constructor; + if ( + value instanceof DataObject || + ctor === Object || + ctor === undefined || + typeof (value).copyFrom === "function" + ) { + return CloneManager._cloneClassInstance(value, reuse, cloneMap); } return value; }; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 6cea3d7ee2..0274135832 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -152,43 +152,9 @@ export class CloneManager { /** * @internal - * The single container classification point. Invariant: every shape returning true MUST have - * a dedicated `_deepClone` branch. `constructor === undefined` = null-prototype objects. + * ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. */ - static _isContainer(value: object): boolean { - return ( - Array.isArray(value) || - value instanceof Map || - value instanceof Set || - ArrayBuffer.isView(value) || - value.constructor === Object || - value.constructor === undefined - ); - } - - /** - * @internal - * Clone one object graph: the structure is fresh, while every member re-enters the gate and - * follows its own semantics. Cycles / shared sub-graphs dedup through the identity map. - * Pure structural logic — no reference to any type-family predicate — so callers that already - * know a value is Deep-mode (e.g. `DataObject.clone()`) can skip `_cloneValue`'s mode resolution. - */ - static _deepClone(value: any, reuse: any, cloneMap: Map): any { - const existing = cloneMap.get(value); - if (existing) return existing; - - // Each shape identifies and clones itself in one place — no separate classifier to keep in - // sync (the four container shapes must precede the class-instance fallback, which would - // otherwise field-walk a Map / Set / view into garbage). - if (ArrayBuffer.isView(value)) return CloneManager._cloneBufferView(value, reuse, cloneMap); - if (Array.isArray(value)) return CloneManager._cloneArray(value, cloneMap); - if (value instanceof Map) return CloneManager._cloneMapValue(value, cloneMap); - if (value instanceof Set) return CloneManager._cloneSetValue(value, cloneMap); - return CloneManager._cloneClassInstance(value, reuse, cloneMap); - } - - /** ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. */ - private static _cloneBufferView(value: ArrayBufferView, reuse: any, cloneMap: Map): ArrayBufferView { + static _cloneBufferView(value: ArrayBufferView, reuse: any, cloneMap: Map): ArrayBufferView { let dst: ArrayBufferView; if (value instanceof DataView) { const src = value; @@ -218,8 +184,11 @@ export class CloneManager { return dst; } - /** Array — fresh instance, each element re-entering the gate. */ - private static _cloneArray(value: any[], cloneMap: Map): any[] { + /** + * @internal + * Array — fresh instance, each element re-entering the gate. + */ + static _cloneArray(value: any[], cloneMap: Map): any[] { const dst = new Array(value.length); cloneMap.set(value, dst); for (let i = 0, n = value.length; i < n; i++) { @@ -228,8 +197,11 @@ export class CloneManager { return dst; } - /** Map — fresh instance, each key and value re-entering the gate. */ - private static _cloneMapValue(value: Map, cloneMap: Map): Map { + /** + * @internal + * Map — fresh instance, each key and value re-entering the gate. + */ + static _cloneMapValue(value: Map, cloneMap: Map): Map { const dst = new Map(); cloneMap.set(value, dst); for (const entry of value) { @@ -241,8 +213,11 @@ export class CloneManager { return dst; } - /** Set — fresh instance, each member re-entering the gate. */ - private static _cloneSetValue(value: Set, cloneMap: Map): Set { + /** + * @internal + * Set — fresh instance, each member re-entering the gate. + */ + static _cloneSetValue(value: Set, cloneMap: Map): Set { const dst = new Set(); cloneMap.set(value, dst); for (const v of value) { @@ -252,11 +227,12 @@ export class CloneManager { } /** + * @internal * Class instance or plain / null-prototype object — reuse a compatible preset or construct bare, * then copy via a value type's `copyFrom` (Vector3 / Color / ...) or, failing that, by walking * every field; finally run its `_cloneTo` hook. */ - private static _cloneClassInstance(value: any, reuse: any, cloneMap: Map): any { + static _cloneClassInstance(value: any, reuse: any, cloneMap: Map): any { const ctor = value.constructor; // Compatible reuse: a distinct instance of the exact same type (null-prototype matches null-prototype). const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 5fb1d9f7d5..59e327b4bb 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -13,7 +13,7 @@ export interface ICustomClone { _cloneTo?(target: ICustomClone, cloneMap?: Map): void; /** * @internal - * Value-type marker — `_deepClone` copies via this instead of walking fields. + * Value-type marker — `_cloneClassInstance` copies via this instead of walking fields. */ copyFrom?(source: ICustomClone): void; } From 86f4bef3a19dc1643fd7aaf7ce206814a97fb0c4 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 13:37:57 +0800 Subject: [PATCH 068/109] refactor(clone): inline per-shape clone; fix @deepClone on a default-less class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the default/decorator dispatch, kept together because the second rides on the first: 1. Inline the five _clone* helpers (buffer view / array / map / set / class-instance) back into _cloneByDefault. They had a single call site each; as named statics they only added a layer to read through. _isContainer and the _deepClone dispatcher are already gone, so _cloneByDefault is now the one place that identifies a value and clones it. _bareConstruct stays (still called for bare construction); TypedArray / ICustomClone imports move to CloneDefaults with the code. 2. Fix a regression from dropping _deepClone: `@deepClone` on a class with no deep-clone default (not DataObject / math / container) was routed through _cloneByDefault, which shares such a class — so the explicit deep-clone request silently became a share. The old _deepClone field-walked any object unconditionally. A `forceDeep` flag on _cloneByDefault restores it: the default path shares a default-less class, the `@deepClone` path (the sole caller passing forceDeep=true) field-walks it. This is `@deepClone`'s only irreplaceable job — deep-copying a type that can't or didn't extend DataObject (e.g. a third-party class); without it the decorator would be dead. Dedup/cycle handling is unchanged: both paths share _cloneByDefault's leading cloneMap.get and the pre-walk cloneMap.set. Tests: `@deepClone` forces a deep copy of a default-less class (reverse- verified — fails without forceDeep), and the same class without the decorator is shared. 1590/1590, benchmark within noise (130us particle / 3010us tree). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneDefaults.ts | 102 ++++++++++++++++---- packages/core/src/clone/CloneManager.ts | 118 ++--------------------- tests/src/core/CloneManager.test.ts | 54 +++++++++++ 3 files changed, 144 insertions(+), 130 deletions(-) diff --git a/packages/core/src/clone/CloneDefaults.ts b/packages/core/src/clone/CloneDefaults.ts index 3775701643..3b1912cc83 100644 --- a/packages/core/src/clone/CloneDefaults.ts +++ b/packages/core/src/clone/CloneDefaults.ts @@ -2,6 +2,7 @@ import { Component } from "../Component"; import { Entity } from "../Entity"; import { ReferResource } from "../asset/ReferResource"; import { DataObject } from "../base/DataObject"; +import { TypedArray } from "../base/Constant"; import { UpdateFlag } from "../UpdateFlag"; import { UpdateFlagManager } from "../UpdateFlagManager"; import { DisorderedArray } from "../utils/DisorderedArray"; @@ -15,18 +16,69 @@ import { ICustomClone } from "./ComponentCloner"; // instance the gate can meet is engine-constructed, always same-realm with the gate. // Each type is identified and handled in one step — no intermediate CloneMode, and no separate // "is this a container" predicate whose truths must each be mirrored by a clone branch elsewhere. -CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map): any => { +// `forceDeep` is the one difference between the default path and a `@deepClone`'d field: it flips +// the sole ambiguous case — a class instance with no deep-clone default (not DataObject / math / +// container) — from "share" to "field-walk". Everything else resolves the same on both paths. +CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map, forceDeep = false): any => { // Already produced for this source in the graph — a deep clone made earlier, or, for an // Entity / Component inside the cloned subtree, its clone registered at tree-build time. Reusing // it dedups shared / cyclic references and doubles as the in-subtree remap. const existing = cloneMap.get(value); if (existing) return existing; - // Containers — identify and clone in one step. - if (ArrayBuffer.isView(value)) return CloneManager._cloneBufferView(value, reuse, cloneMap); - if (Array.isArray(value)) return CloneManager._cloneArray(value, cloneMap); - if (value instanceof Map) return CloneManager._cloneMapValue(value, cloneMap); - if (value instanceof Set) return CloneManager._cloneSetValue(value, cloneMap); + // Containers — a fresh structure, each member re-entering the gate. + if (ArrayBuffer.isView(value)) { + // Byte copy into a reused view of matching layout, else a fresh one. + let dst: ArrayBufferView; + if (value instanceof DataView) { + if (reuse instanceof DataView && reuse !== value && reuse.byteLength === value.byteLength) { + new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + ); + dst = reuse; + } else { + dst = new DataView(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + } + } else { + const src = value; + if ( + reuse && + reuse !== value && + reuse.constructor === src.constructor && + (reuse).length === src.length + ) { + (reuse).set(src); + dst = reuse; + } else { + dst = src.slice(); + } + } + cloneMap.set(value, dst); + return dst; + } + if (Array.isArray(value)) { + const dst = new Array(value.length); + cloneMap.set(value, dst); + for (let i = 0, n = value.length; i < n; i++) dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); + return dst; + } + if (value instanceof Map) { + const dst = new Map(); + cloneMap.set(value, dst); + for (const entry of value) { + dst.set( + CloneManager._cloneValue(entry[0], undefined, cloneMap), + CloneManager._cloneValue(entry[1], undefined, cloneMap) + ); + } + return dst; + } + if (value instanceof Set) { + const dst = new Set(); + cloneMap.set(value, dst); + for (const v of value) dst.add(CloneManager._cloneValue(v, undefined, cloneMap)); + return dst; + } // Engine-bound families — never deep cloned. An Entity / Component reaching here is outside the // cloned subtree (inside was returned by the dedup above), so its original reference is kept. @@ -41,19 +93,33 @@ CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Mapvalue).constructor; - if ( - value instanceof DataObject || - ctor === Object || - ctor === undefined || - typeof (value).copyFrom === "function" - ) { - return CloneManager._cloneClassInstance(value, reuse, cloneMap); + // Everything remaining is a non-container object. A compatible preset of the exact same type is + // reused as the clone target; otherwise a bare instance is constructed (null-prototype objects + // have no constructor, so rebuild as such). + const ctor = (value).constructor; + const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; + + // Math value type (Vector3, Color, ...) — a class instance carrying a callable copyFrom; copy + // via it. Math cannot depend on core, so it cannot extend DataObject and is recognized this way. + if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { + const dst = (reusable ?? CloneManager._bareConstruct(ctor)); + cloneMap.set(value, dst); + dst.copyFrom(value); + (value)._cloneTo?.(dst, cloneMap); + return dst; } + + // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any + // other class instance only when `@deepClone` forces it (default shares it — see `forceDeep`). + if (value instanceof DataObject || ctor === Object || ctor === undefined || forceDeep) { + const dst = reusable ?? (ctor ? CloneManager._bareConstruct(ctor) : Object.create(null)); + cloneMap.set(value, dst); + CloneManager.deepCloneObject(value, dst, cloneMap); + (value)._cloneTo?.(dst, cloneMap); + return dst; + } + + // A class instance with no deep-clone default, on the default path — shared. return value; }; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 0274135832..e23084148e 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,7 +1,5 @@ import { IReferable } from "../asset/IReferable"; -import { TypedArray } from "../base/Constant"; import { Logger } from "../base/Logger"; -import { ICustomClone } from "./ComponentCloner"; import { CloneMode } from "./enums/CloneMode"; /** @@ -99,9 +97,10 @@ export class CloneManager { ); return value; } - // Neither family — attempt an actual deep clone via the same dispatch an undecorated field - // would use (container / DataObject / math / runtime-state / fallback share). - return CloneManager._cloneByDefault(value, reuse, cloneMap); + // Neither family — deep clone through the same dispatch the default path uses, but with + // `forceDeep`: `@deepClone` is an explicit request, so even a class with no deep-clone default + // (which the default path would share) is field-walked here. + return CloneManager._cloneByDefault(value, reuse, cloneMap, true); } /** @@ -136,7 +135,7 @@ export class CloneManager { * `CloneDefaults` (a module-graph sink) so the gate never imports the intrinsic classes — a * top-level class import here would reorder module evaluation and break `extends` chains. */ - static _cloneByDefault: (value: object, reuse: any, cloneMap: Map) => any; + static _cloneByDefault: (value: object, reuse: any, cloneMap: Map, forceDeep?: boolean) => any; /** * @internal @@ -152,115 +151,10 @@ export class CloneManager { /** * @internal - * ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. - */ - static _cloneBufferView(value: ArrayBufferView, reuse: any, cloneMap: Map): ArrayBufferView { - let dst: ArrayBufferView; - if (value instanceof DataView) { - const src = value; - if (reuse instanceof DataView && reuse !== value && reuse.byteLength === src.byteLength) { - new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( - new Uint8Array(src.buffer, src.byteOffset, src.byteLength) - ); - dst = reuse; - } else { - dst = new DataView(src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength)); - } - } else { - const src = value; - if ( - reuse && - reuse !== value && - reuse.constructor === src.constructor && - (reuse).length === src.length - ) { - (reuse).set(src); - dst = reuse; - } else { - dst = src.slice(); - } - } - cloneMap.set(value, dst); - return dst; - } - - /** - * @internal - * Array — fresh instance, each element re-entering the gate. - */ - static _cloneArray(value: any[], cloneMap: Map): any[] { - const dst = new Array(value.length); - cloneMap.set(value, dst); - for (let i = 0, n = value.length; i < n; i++) { - dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); - } - return dst; - } - - /** - * @internal - * Map — fresh instance, each key and value re-entering the gate. - */ - static _cloneMapValue(value: Map, cloneMap: Map): Map { - const dst = new Map(); - cloneMap.set(value, dst); - for (const entry of value) { - dst.set( - CloneManager._cloneValue(entry[0], undefined, cloneMap), - CloneManager._cloneValue(entry[1], undefined, cloneMap) - ); - } - return dst; - } - - /** - * @internal - * Set — fresh instance, each member re-entering the gate. - */ - static _cloneSetValue(value: Set, cloneMap: Map): Set { - const dst = new Set(); - cloneMap.set(value, dst); - for (const v of value) { - dst.add(CloneManager._cloneValue(v, undefined, cloneMap)); - } - return dst; - } - - /** - * @internal - * Class instance or plain / null-prototype object — reuse a compatible preset or construct bare, - * then copy via a value type's `copyFrom` (Vector3 / Color / ...) or, failing that, by walking - * every field; finally run its `_cloneTo` hook. - */ - static _cloneClassInstance(value: any, reuse: any, cloneMap: Map): any { - const ctor = value.constructor; - // Compatible reuse: a distinct instance of the exact same type (null-prototype matches null-prototype). - const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; - - // Value type (Vector3, Color, Matrix, ...) — a class instance carrying a callable copyFrom. - // Plain / null-prototype objects never take this branch, even when a `copyFrom` field rides in the data. - if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { - const dst = (reusable ?? CloneManager._bareConstruct(ctor)); - cloneMap.set(value, dst); - dst.copyFrom(value); - (value)._cloneTo?.(dst, cloneMap); - return dst; - } - - // Reuse or construct (null-prototype objects have no ctor: rebuild as such), then populate all - // fields (opt-out) and run its `_cloneTo` hook. - const dst = reusable ?? (ctor ? CloneManager._bareConstruct(ctor) : Object.create(null)); - cloneMap.set(value, dst); - CloneManager.deepCloneObject(value, dst, cloneMap); - (value)._cloneTo?.(dst, cloneMap); - return dst; - } - - /** * A deep-cloned instance without a compatible preset is constructed bare; name the contract * when that fails instead of surfacing the constructor's raw error. */ - private static _bareConstruct(ctor: new () => any): any { + static _bareConstruct(ctor: new () => any): any { try { return new ctor(); } catch (e) { diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 5aab921af0..bfbe3b1565 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -262,6 +262,23 @@ class SharedDefaultTableScript extends Script { view: DataView = SharedDefaultTableScript.DEFAULT_VIEW; } +/** Plain class with no deep-clone default (not DataObject / math / container). */ +class PlainConfig { + count = 0; + nested = { x: 1 }; +} + +/** Script @deepClone-ing a class that has no deep-clone default — must force a deep copy. */ +class ForcedDeepScript extends Script { + @deepClone + config: 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 (must fall back to remap, never construct) */ class DeepEntityRefScript extends Script { @deepClone @@ -615,6 +632,43 @@ describe("Clone remap", async () => { 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("@assignmentClone function field shares the source function (decorator wins over reuse)", () => { const rootEntity = scene.createRootEntity("root"); const parent = rootEntity.createChild("parent"); From ec3e90bc698504b4390105781844300e13bc5df5 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 13:46:00 +0800 Subject: [PATCH 069/109] fix(clone): throw on @deepClone of an engine-bound type instead of falling back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field decorator is the developer's explicit intent and takes absolute priority: honor it, or surface that it can't be honored — never silently substitute a different behavior. @deepClone on an Entity / Component reference or an asset previously warned and fell back to remap / share; the warning is easy to miss and the clone then behaves unlike the requested deep copy, hiding the mistake. It now throws, naming the type and how to fix it (remove the decorator for the default remap / share, or use the asset's own clone API). Undecorated fields are unaffected — an Entity ref with no decorator still resolves to remap by default. Only the explicit @deepClone-on-engine-bound misuse changes, from silent fallback to a thrown error. Tests rewritten from asserting fallback to asserting the throw; docs updated. 1590/1590. Co-Authored-By: Claude Fable 5 --- docs/en/core/clone.mdx | 4 ++-- docs/zh/core/clone.mdx | 4 ++-- packages/core/src/clone/CloneManager.ts | 18 ++++++++--------- tests/src/core/CloneManager.test.ts | 26 ++++++------------------- 4 files changed, 19 insertions(+), 33 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index c3f4f0f999..8efb0f329c 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -89,7 +89,7 @@ In addition to the default type-driven rules, the engine also provides "clone de | :--- | :--- | | [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 during cloning. The field gets a fresh, independent structure, and each inner member is cloned again according to its own semantics. Engine-bound values cannot be deep cloned: `Entity` / `Component` references fall back to remapping with a warning, and assets fall back to sharing with a warning. | +| [deepClone](/apis/core/#deepClone) | Deep clone the field during cloning. The field gets a fresh, independent structure, and each inner member is cloned again according to its own semantics. Engine-bound values cannot be deep cloned: `@deepClone` on an `Entity` / `Component` reference or an asset throws — remove the decorator to get the default behavior (remapping / sharing), 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. @@ -142,7 +142,7 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - Field decorators have the highest priority and also take effect on the fields of nested classes at any depth of the cloned object graph. - `deepClone` recursively clones the structure, while each inner member is still cloned according to its own semantics: assets stay shared, entity references are remapped, and nested field decorators still apply. - - `deepClone` cannot deep clone engine-bound objects: `Entity` / `Component` references fall back to remapping and assets fall back to sharing, both with a warning. If you need a real copy of an asset, use the asset's own clone API. + - `deepClone` cannot deep clone engine-bound objects: `@deepClone` on an `Entity` / `Component` reference or an asset throws, because the explicit intent can't be honored. Remove the decorator to fall back to the default (remapping / sharing), 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 diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index e713ee8cbe..5ff13a2c4d 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -90,7 +90,7 @@ console.log(cloneScript.texture === script.texture); // output is true,资产 | :--- | :--- | | [ignoreClone](/apis/core/#ignoreClone) | 克隆时对字段进行忽略,克隆体保留自身构造时的值。 | | [assignmentClone](/apis/core/#assignmentClone) | 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型,克隆体将与源共享同一个引用。 | -| [deepClone](/apis/core/#deepClone) | 克隆时对字段进行深克隆。字段会获得全新且独立的结构,内部每个成员再按各自的语义继续克隆。引擎绑定的对象无法被深克隆:`Entity` / `Component` 引用会回退为重映射并告警,资产会回退为共享并告警。| +| [deepClone](/apis/core/#deepClone) | 克隆时对字段进行深克隆。字段会获得全新且独立的结构,内部每个成员再按各自的语义继续克隆。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用或资产使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为(重映射 / 共享),或使用资产自身的克隆 API 复制资产。| `@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 `@deepClone`,希望共享就使用 `@assignmentClone`。 @@ -143,7 +143,7 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - 字段装饰器优先级最高,并且对克隆对象图任意深度上嵌套类的字段同样生效。 - `deepClone` 会对结构进行深度递归克隆,内部每个成员仍按各自的语义克隆:资产保持共享,实体引用被重映射,嵌套字段上的装饰器依然生效。 - - `deepClone` 无法深克隆引擎绑定的对象:`Entity` / `Component` 引用会回退为重映射,资产会回退为共享,二者都会打印告警。如果需要真正复制一份资产,请使用资产自身的克隆 API。 + - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用或资产使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为(重映射 / 共享),或使用资产自身的克隆 API 得到真正的拷贝。 - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 ## 克隆与资产引用计数 diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index e23084148e..aba06b99c3 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -82,20 +82,20 @@ export class CloneManager { if (fieldMode === undefined) return CloneManager._cloneByDefault(value, reuse, cloneMap); if (fieldMode === CloneMode.Assignment) return value; - // fieldMode === Deep: error recovery, not a priority rule — engine-bound instances can't be - // deep cloned; recover to the type's real action (remap / share) and warn. + // fieldMode === Deep: @deepClone is the developer's explicit intent. If the value can't be + // deep cloned (engine-bound: Entity / Component references, or assets), that intent is a + // mistake — throw to surface it, never silently fall back to remap / share. if (CloneManager._isRemapType(value)) { - Logger.warn( - `CloneManager: "${value.constructor.name}" cannot be deep cloned; @deepClone on this field falls back to remap.` + throw new Error( + `CloneManager: @deepClone cannot deep clone "${value.constructor.name}" — Entity / Component ` + + `references are engine-bound. Remove @deepClone to remap the reference by default.` ); - return cloneMap.get(value) ?? value; } if (CloneManager._isCountedResource(value)) { - Logger.warn( - `CloneManager: "${value.constructor.name}" is an engine-bound asset and cannot be deep cloned; ` + - `@deepClone on this field falls back to sharing (use the asset's own clone() API to copy it).` + throw new Error( + `CloneManager: @deepClone cannot deep clone "${value.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.` ); - return value; } // Neither family — deep clone through the same dispatch the default path uses, but with // `forceDeep`: `@deepClone` is an explicit request, so even a class with no deep-clone default diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index bfbe3b1565..7e7b9e31df 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -591,42 +591,28 @@ describe("Clone remap", async () => { rootEntity.destroy(); }); - it("@deepClone entity ref falls back to remap instead of constructing a broken entity", () => { + 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 external = rootEntity.createChild("external"); const script = parent.addComponent(DeepEntityRefScript); - - // In-subtree ref remaps to the clone's entity. script.target = child; - let cloned = parent.clone(); - expect(cloned.getComponent(DeepEntityRefScript).target).eq(cloned.children[0]); - // Out-of-subtree ref keeps the original reference (never `new Entity()` without engine). - script.target = external; - cloned = parent.clone(); - expect(cloned.getComponent(DeepEntityRefScript).target).eq(external); - expect(cloned.getComponent(DeepEntityRefScript).target.engine).eq(engine); + // 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 asset ref falls back to sharing instead of constructing a broken asset", () => { + 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); - const baseline = texture.refCount; script.texture = texture; - const cloned = parent.clone(); - const cs = cloned.getComponent(DeepAssetRefScript); - - // Shared, never `new Texture2D()` without an engine; the slot behaves like an - // undecorated asset slot (owns one reference under the slot contract). - expect(cs.texture).eq(texture); - expect(texture.refCount).eq(baseline + 1); + expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone/); rootEntity.destroy(); texture.destroy(true); From c547a038fcc060ddab4d79b79c80ea2ec0d51fb0 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 18:44:05 +0800 Subject: [PATCH 070/109] refactor(clone): split execution into CloneUtil, drop the injection slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloneManager no longer performs cloning: it is now only the field-mode registry (the three decorators plus _registerFieldMode), and CloneUtil owns the execution — the gate, the per-type dispatch, deepCloneObject and slot settlement. This deletes the injection indirection entirely (_cloneByDefault / _isRemapType / _isCountedResource were function slots filled at load time by a sink module, purely to keep engine classes out of CloneManager); CloneUtil imports those classes directly. The split is load-bearing, not cosmetic: every class carrying a clone decorator imports CloneManager while it is still being defined, so a transitive engine-class import from there reorders module evaluation and breaks `extends` chains (rollup: "Super expression must either be null or a function" — reproduced before the split). CloneManager now imports nothing but the CloneMode enum, and CloneUtil is only pulled in by clone entry points, after class definitions have settled. Fixed while moving: - Functions were being short-circuited by the `typeof !== "object"` guard before reaching their branch, losing "the default keeps the clone's own constructor-rebound binding" (the branch was dead code). - Map/Set preset reuse didn't clear the reused container, so the clone's own constructor-built entries survived alongside the source's. - Array/Map/Set preset reuse lacked the `reuse !== value` guard the non-container path has, so a clone still aliasing a class-level shared default was filled in place — writing through into the source. - @deepClone on an engine-bound value warned and fell back; restored to throwing (per the decorator-is-explicit-intent rule), and moved ahead of the dedup so an in-subtree Entity can't quietly slip past it. - Signal argument remapping was widened to look up any object in the identity map, which makes the result depend on field-walk order (only Remap types are registered before the walk). Restored to Remap-only. Tests for the container-preset fixes (reverse-verified: both fail on the previous reuse logic). 1592/1592, benchmark within noise (132us particle / 2926us tree). Co-Authored-By: Claude Fable 5 --- packages/core/src/Signal.ts | 4 +- packages/core/src/clone/CloneDefaults.ts | 128 ------- packages/core/src/clone/CloneManager.ts | 131 +------- packages/core/src/clone/CloneUtil.ts | 317 ++++++++++++++++++ packages/core/src/clone/ComponentCloner.ts | 6 +- packages/core/src/index.ts | 3 +- .../src/particle/modules/CustomDataModule.ts | 7 +- packages/core/src/shader/ShaderData.ts | 5 +- tests/src/core/CloneManager.test.ts | 64 +++- 9 files changed, 399 insertions(+), 266 deletions(-) delete mode 100644 packages/core/src/clone/CloneDefaults.ts create mode 100644 packages/core/src/clone/CloneUtil.ts diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index 771ad48c91..555e755afa 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,6 +1,6 @@ import { DataObject } from "./base/DataObject"; import { Component } from "./Component"; -import { CloneManager } from "./clone/CloneManager"; +import { CloneUtil } from "./clone/CloneUtil"; import { SafeLoopArray } from "./utils/SafeLoopArray"; /** @@ -150,7 +150,7 @@ export class Signal extends DataObject { const arg = args[i]; // Only Entity/Component references (Remap types) are remapped; other objects are shared // deterministically (looking them up in the identity map would depend on field-walk order). - clonedArgs[i] = CloneManager._isRemapType(arg) ? (cloneMap.get(arg) ?? arg) : arg; + clonedArgs[i] = CloneUtil._isRemapType(arg) ? (cloneMap.get(arg) ?? arg) : arg; } return clonedArgs; } diff --git a/packages/core/src/clone/CloneDefaults.ts b/packages/core/src/clone/CloneDefaults.ts deleted file mode 100644 index 3b1912cc83..0000000000 --- a/packages/core/src/clone/CloneDefaults.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Component } from "../Component"; -import { Entity } from "../Entity"; -import { ReferResource } from "../asset/ReferResource"; -import { DataObject } from "../base/DataObject"; -import { TypedArray } from "../base/Constant"; -import { UpdateFlag } from "../UpdateFlag"; -import { UpdateFlagManager } from "../UpdateFlagManager"; -import { DisorderedArray } from "../utils/DisorderedArray"; -import { SafeLoopArray } from "../utils/SafeLoopArray"; -import { CloneManager } from "./CloneManager"; -import { ICustomClone } from "./ComponentCloner"; - -// The built-in default decision lives here — a module-graph sink — so the gate itself never -// imports the intrinsic classes (a top-level class import inside CloneManager would reorder -// module evaluation and break `extends` chains). All families resolve by `instanceof`: every -// instance the gate can meet is engine-constructed, always same-realm with the gate. -// Each type is identified and handled in one step — no intermediate CloneMode, and no separate -// "is this a container" predicate whose truths must each be mirrored by a clone branch elsewhere. -// `forceDeep` is the one difference between the default path and a `@deepClone`'d field: it flips -// the sole ambiguous case — a class instance with no deep-clone default (not DataObject / math / -// container) — from "share" to "field-walk". Everything else resolves the same on both paths. -CloneManager._cloneByDefault = (value: object, reuse: any, cloneMap: Map, forceDeep = false): any => { - // Already produced for this source in the graph — a deep clone made earlier, or, for an - // Entity / Component inside the cloned subtree, its clone registered at tree-build time. Reusing - // it dedups shared / cyclic references and doubles as the in-subtree remap. - const existing = cloneMap.get(value); - if (existing) return existing; - - // Containers — a fresh structure, each member re-entering the gate. - if (ArrayBuffer.isView(value)) { - // Byte copy into a reused view of matching layout, else a fresh one. - let dst: ArrayBufferView; - if (value instanceof DataView) { - if (reuse instanceof DataView && reuse !== value && reuse.byteLength === value.byteLength) { - new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( - new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - ); - dst = reuse; - } else { - dst = new DataView(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); - } - } else { - const src = value; - if ( - reuse && - reuse !== value && - reuse.constructor === src.constructor && - (reuse).length === src.length - ) { - (reuse).set(src); - dst = reuse; - } else { - dst = src.slice(); - } - } - cloneMap.set(value, dst); - return dst; - } - if (Array.isArray(value)) { - const dst = new Array(value.length); - cloneMap.set(value, dst); - for (let i = 0, n = value.length; i < n; i++) dst[i] = CloneManager._cloneValue(value[i], undefined, cloneMap); - return dst; - } - if (value instanceof Map) { - const dst = new Map(); - cloneMap.set(value, dst); - for (const entry of value) { - dst.set( - CloneManager._cloneValue(entry[0], undefined, cloneMap), - CloneManager._cloneValue(entry[1], undefined, cloneMap) - ); - } - return dst; - } - if (value instanceof Set) { - const dst = new Set(); - cloneMap.set(value, dst); - for (const v of value) dst.add(CloneManager._cloneValue(v, undefined, cloneMap)); - return dst; - } - - // Engine-bound families — never deep cloned. An Entity / Component reaching here is outside the - // cloned subtree (inside was returned by the dedup above), so its original reference is kept. - if (value instanceof Entity || value instanceof Component) return value; - if (value instanceof ReferResource) return value; - if ( - value instanceof UpdateFlagManager || - value instanceof UpdateFlag || - value instanceof DisorderedArray || - value instanceof SafeLoopArray - ) { - return reuse; - } - - // Everything remaining is a non-container object. A compatible preset of the exact same type is - // reused as the clone target; otherwise a bare instance is constructed (null-prototype objects - // have no constructor, so rebuild as such). - const ctor = (value).constructor; - const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; - - // Math value type (Vector3, Color, ...) — a class instance carrying a callable copyFrom; copy - // via it. Math cannot depend on core, so it cannot extend DataObject and is recognized this way. - if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { - const dst = (reusable ?? CloneManager._bareConstruct(ctor)); - cloneMap.set(value, dst); - dst.copyFrom(value); - (value)._cloneTo?.(dst, cloneMap); - return dst; - } - - // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any - // other class instance only when `@deepClone` forces it (default shares it — see `forceDeep`). - if (value instanceof DataObject || ctor === Object || ctor === undefined || forceDeep) { - const dst = reusable ?? (ctor ? CloneManager._bareConstruct(ctor) : Object.create(null)); - cloneMap.set(value, dst); - CloneManager.deepCloneObject(value, dst, cloneMap); - (value)._cloneTo?.(dst, cloneMap); - return dst; - } - - // A class instance with no deep-clone default, on the default path — shared. - return value; -}; - -CloneManager._isRemapType = (value: any): boolean => value instanceof Entity || value instanceof Component; - -CloneManager._isCountedResource = (value: any): boolean => value instanceof ReferResource; diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index aba06b99c3..6bb0df223f 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,11 +1,10 @@ -import { IReferable } from "../asset/IReferable"; -import { Logger } from "../base/Logger"; import { CloneMode } from "./enums/CloneMode"; /** * Property decorator — deep clone this field, overriding the value type's default clone mode - * (field-level decorators have the highest priority). Engine-bound values (entities / assets) - * cannot be deep cloned — they fall back to remap / share with a warning. + * (field-level decorators have the highest priority). A decorator is an explicit intent: if the + * value can't be deep cloned (an entity reference or an asset), cloning throws rather than + * silently falling back. */ export function deepClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); @@ -27,28 +26,12 @@ export function ignoreClone(target: object, propertyKey: string): void { /** * @internal - * Clone manager. Opt-out model: every enumerable field is cloned unless `@ignoreClone`, - * with the mode resolved by `_cloneValue`. - * - * Ref count: a component top-level slot sharing a registered resource owns one reference; - * releasing it on destroy is the owning class's contract. Nested levels never count at the - * gate — a nested class pairs its own acquisition (`_cloneTo` +1 / destroy -1). + * Field-level clone mode registry. Deliberately free of any engine class import: every class that + * uses a clone decorator imports this module while it is still being defined, so pulling an + * engine class in here (directly or through `CloneUtil`) reorders module evaluation and breaks + * `extends` chains. The cloning itself lives in `CloneUtil`. */ export class CloneManager { - /** - * Clone all enumerable fields of source into target; each field goes through the clone gate, - * honoring field-level decorators. - */ - static deepCloneObject(source: any, target: object, cloneMap: Map): void { - // Resolved once per object (a single prototype-chain walk), not once per field. - const fieldModes = source._fieldModes; - for (const k in source) { - const fieldMode = fieldModes?.[k]; - if (fieldMode === CloneMode.Ignore) continue; - target[k] = CloneManager._cloneValue(source[k], target[k], cloneMap, fieldMode); - } - } - /** * @internal * Register a field-level clone mode (highest priority — overrides the built-in default decision). @@ -65,104 +48,4 @@ export class CloneManager { } target._fieldModes[propertyKey] = mode; } - - /** - * @internal - * Clone gate. Field decorator (highest priority) is handled inline; with no field override, - * dispatch goes straight to `_cloneByDefault` — one pass from value to action, no intermediate - * `CloneMode` to compute and re-switch on. - */ - static _cloneValue(value: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { - // Explicit decorator shares the function; default keeps the clone's own rebound binding. - if (typeof value === "function") { - return fieldMode === undefined && typeof reuse === "function" ? reuse : value; - } - // `typeof`, not `instanceof` — null-prototype / cross-realm objects lack local Object.prototype. - if (value === null || typeof value !== "object") return value; - if (fieldMode === undefined) return CloneManager._cloneByDefault(value, reuse, cloneMap); - if (fieldMode === CloneMode.Assignment) return value; - - // fieldMode === Deep: @deepClone is the developer's explicit intent. If the value can't be - // deep cloned (engine-bound: Entity / Component references, or assets), that intent is a - // mistake — throw to surface it, never silently fall back to remap / share. - if (CloneManager._isRemapType(value)) { - throw new Error( - `CloneManager: @deepClone cannot deep clone "${value.constructor.name}" — Entity / Component ` + - `references are engine-bound. Remove @deepClone to remap the reference by default.` - ); - } - if (CloneManager._isCountedResource(value)) { - throw new Error( - `CloneManager: @deepClone cannot deep clone "${value.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.` - ); - } - // Neither family — deep clone through the same dispatch the default path uses, but with - // `forceDeep`: `@deepClone` is an explicit request, so even a class with no deep-clone default - // (which the default path would share) is field-walked here. - return CloneManager._cloneByDefault(value, reuse, cloneMap, true); - } - - /** - * @internal - * Settle a slot's counted ownership after the gate wrote it: an unchanged slot keeps its - * account, a displaced owned counted preset releases one reference, and a slot sharing the - * source's counted value acquires one. Destroy releases the slot (class contract). - */ - static _transferSlotOwnership(cloned: any, sourceValue: any, preset: any): void { - // Slot content unchanged (Ignore kept / value type copied in place / function reused). - if (cloned === preset) return; - if (CloneManager._isCountedResource(preset)) { - const presetRefCount = (<{ refCount?: number }>preset).refCount; - presetRefCount !== undefined && - presetRefCount <= 0 && - Logger.error( - `CloneManager: 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); - } - // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path - // returns a registered resource as-is), so it owns one reference. - if (cloned === sourceValue && CloneManager._isCountedResource(cloned)) { - (cloned)._addReferCount(1); - } - } - - /** - * @internal - * Decide-and-execute for a value with no explicit field mode, by type family; injected from - * `CloneDefaults` (a module-graph sink) so the gate never imports the intrinsic classes — a - * top-level class import here would reorder module evaluation and break `extends` chains. - */ - static _cloneByDefault: (value: object, reuse: any, cloneMap: Map, forceDeep?: boolean) => any; - - /** - * @internal - * Whether the value is an engine-bound Remap type (Entity / Component); injected from `CloneDefaults`. - */ - static _isRemapType: (value: any) => boolean; - - /** - * @internal - * Counted = the ReferResource family only; injected from `CloneDefaults`. - */ - static _isCountedResource: (value: any) => boolean; - - /** - * @internal - * A deep-cloned instance without a compatible preset is constructed bare; name the contract - * when that fails instead of surfacing the constructor's raw error. - */ - static _bareConstruct(ctor: new () => any): any { - try { - return new ctor(); - } catch (e) { - throw new Error( - `CloneManager: 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}` - ); - } - } } diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts new file mode 100644 index 0000000000..a630075a0c --- /dev/null +++ b/packages/core/src/clone/CloneUtil.ts @@ -0,0 +1,317 @@ +import { ReferResource } from "../asset/ReferResource"; +import { IReferable } from "../asset/IReferable"; +import { Logger } from "../base/Logger"; +import { TypedArray } from "../base/Constant"; +import { DataObject } from "../base/DataObject"; +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 + * Clone execution. Identifies each value by type and clones it in one step — a field decorator + * (highest priority) is honored here, and with no decorator the built-in default by type family + * applies. Engine classes are imported here rather than in `CloneManager`, which every decorated + * class pulls in while still being defined. + */ +export class CloneUtil { + /** + * @internal + * Clone gate for one value. + */ + static _cloneValue(source: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { + // A function is a value, not a graph: an explicit decorator shares the source function, while + // the default keeps the clone's own constructor-rebound binding when it has one. + if (typeof source === "function") { + return fieldMode === undefined && typeof reuse === "function" ? reuse : source; + } + if (source === null || typeof source !== "object") return source; + + switch (fieldMode) { + case CloneMode.Assignment: + return source; + case CloneMode.Deep: + // `@deepClone` is an explicit intent: force a deep copy, and throw if it can't be honored. + return CloneUtil._cloneByType(source, reuse, cloneMap, true); + default: + return CloneUtil._cloneByType(source, reuse, cloneMap); + } + } + + /** + * @internal + * Clone all enumerable fields of source into target; each field goes back through the gate, + * honoring field-level decorators. + */ + static deepCloneObject(source: any, target: object, cloneMap: Map): void { + // Resolved once per object (a single prototype-chain walk), not once per field. + const fieldModes = source._fieldModes; + for (const k in source) { + const fieldMode = fieldModes?.[k]; + if (fieldMode === CloneMode.Ignore) continue; + target[k] = CloneUtil._cloneValue(source[k], target[k], cloneMap, fieldMode); + } + } + + /** + * @internal + * Identify a value by type family and clone it. `forceDeepClone` (a `@deepClone`'d field) turns + * the one ambiguous case — a class with no deep-clone default — from "share" into "field-walk", + * and makes an engine-bound value throw instead of silently resolving to its default. + */ + static _cloneByType(value: any, reuse: any, cloneMap: Map, forceDeepClone = false): any { + // `@deepClone` on an engine-bound value is a mistake in the developer's intent — reject it + // before the dedup below, which would otherwise quietly resolve an in-subtree Entity to its + // clone and hide the misuse. + if (forceDeepClone) CloneUtil._assertDeepCloneable(value); + + // Already produced for this source in the graph — a clone made earlier, or, for an Entity / + // Component inside the cloned subtree, its clone registered at tree-build time. Reusing it + // dedups shared / cyclic references and doubles as the in-subtree remap. + const existing = cloneMap.get(value); + if (existing) return existing; + + if (ArrayBuffer.isView(value)) { + return CloneUtil._deepCloneArrayBuffer(value, reuse, cloneMap); + } else if (Array.isArray(value)) { + return CloneUtil._deepCloneArray(value, reuse, cloneMap); + } else if (value instanceof Map) { + return CloneUtil._deepCloneMap(value, reuse, cloneMap); + } else if (value instanceof Set) { + return CloneUtil._deepCloneSet(value, reuse, cloneMap); + } else if (value instanceof Entity || value instanceof Component) { + // Outside the cloned subtree (inside was returned by the dedup above): keep the original. + return value; + } else if (value instanceof ReferResource) { + return value; + } else if ( + value instanceof UpdateFlagManager || + value instanceof UpdateFlag || + value instanceof DisorderedArray || + value instanceof SafeLoopArray + ) { + return reuse; + } else { + // A non-container object. A compatible preset of the exact same type is reused as the clone + // target; otherwise a bare instance is constructed (null-prototype objects have no + // constructor, so rebuild as such). + const ctor = (value).constructor; + const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; + + // Math value type (Vector3, Color, ...) — a class instance carrying a callable copyFrom; copy + // via it. Math cannot depend on core, so it cannot extend DataObject and is recognized this + // way. Plain / null-prototype objects never take this branch, even when a `copyFrom` data + // field rides in the payload. + if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { + const dst = (reusable ?? CloneUtil._bareConstruct(ctor)); + cloneMap.set(value, dst); + dst.copyFrom(value); + (value)._cloneTo?.(dst, cloneMap); + return dst; + } + + // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any + // other class instance only when `@deepClone` forces it (the default shares it). + if (value instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { + const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); + cloneMap.set(value, dst); + CloneUtil.deepCloneObject(value, dst, cloneMap); + (value)._cloneTo?.(dst, cloneMap); + return dst; + } + + // A class instance with no deep-clone default, on the default path — shared. + return value; + } + } + + /** + * @internal + * Settle a slot's counted ownership after the gate wrote it: an unchanged slot keeps its + * account, a displaced owned counted preset releases one reference, and a slot sharing the + * source's counted value acquires one. Destroy releases the slot (class contract). + */ + static _transferSlotOwnership(cloned: any, sourceValue: any, preset: any): void { + // Slot content unchanged (Ignore kept / value type copied in place / function reused). + if (cloned === preset) return; + if (CloneUtil._isReferenceResource(preset)) { + 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); + } + // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path + // returns a registered resource as-is), so it owns one reference. + if (cloned === sourceValue && CloneUtil._isReferenceResource(cloned)) { + (cloned)._addReferCount(1); + } + } + + /** + * @internal + * ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. + */ + static _deepCloneArrayBuffer(value: ArrayBufferView, reuse: any, cloneMap: Map): ArrayBufferView { + let dst: ArrayBufferView; + if (value instanceof DataView) { + if (reuse instanceof DataView && reuse !== value && reuse.byteLength === value.byteLength) { + new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + ); + dst = reuse; + } else { + dst = new DataView(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + } + } else { + const src = value; + if ( + reuse && + reuse !== value && + reuse.constructor === src.constructor && + (reuse).length === src.length + ) { + (reuse).set(src); + dst = reuse; + } else { + dst = src.slice(); + } + } + cloneMap.set(value, dst); + return dst; + } + + /** + * @internal + * Array — every element re-enters the gate. A preset of the same type and length is filled in + * place; `reuse !== value` guards the case where the clone still shares the source's array + * (a class-level default table), where writing into it would corrupt the source. + */ + static _deepCloneArray(value: any[], reuse: any, cloneMap: Map): any[] { + const dst = + reuse !== value && + Array.isArray(reuse) && + reuse.constructor === value.constructor && + reuse.length === value.length + ? reuse + : new Array(value.length); + cloneMap.set(value, dst); + for (let i = 0, n = value.length; i < n; i++) dst[i] = CloneUtil._cloneValue(value[i], undefined, cloneMap); + return dst; + } + + /** + * @internal + * Map — every key and value re-enters the gate. A reused preset is cleared first: it holds the + * clone's own constructor-built entries, which would otherwise survive alongside the source's. + */ + static _deepCloneMap(value: Map, reuse: any, cloneMap: Map): Map { + let dst: Map; + if (reuse instanceof Map && reuse !== value && reuse.constructor === value.constructor) { + reuse.clear(); + dst = reuse; + } else { + dst = new Map(); + } + cloneMap.set(value, dst); + for (const entry of value) { + dst.set( + CloneUtil._cloneValue(entry[0], undefined, cloneMap), + CloneUtil._cloneValue(entry[1], undefined, cloneMap) + ); + } + return dst; + } + + /** + * @internal + * Set — every member re-enters the gate. A reused preset is cleared first, for the same reason + * as `Map`. + */ + static _deepCloneSet(value: Set, reuse: any, cloneMap: Map): Set { + let dst: Set; + if (reuse instanceof Set && reuse !== value && reuse.constructor === value.constructor) { + reuse.clear(); + dst = reuse; + } else { + dst = new Set(); + } + cloneMap.set(value, dst); + for (const v of value) dst.add(CloneUtil._cloneValue(v, undefined, cloneMap)); + return dst; + } + + /** + * @internal + * A field decorator is the developer's explicit intent, so `@deepClone` on a value that can't be + * deep cloned is surfaced rather than silently resolved to that value's default behavior. + */ + static _assertDeepCloneable(value: any): void { + if (value instanceof Entity || value instanceof Component) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — Entity / Component ` + + `references are engine-bound. Remove @deepClone to remap the reference by default.` + ); + } + if (value instanceof ReferResource) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${value.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 ( + value instanceof UpdateFlagManager || + value instanceof UpdateFlag || + value instanceof DisorderedArray || + value instanceof SafeLoopArray + ) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — engine runtime state is ` + + `transient and belongs to the instance holding it. Remove @deepClone to keep the clone's own.` + ); + } + } + + /** + * @internal + * Remap types are the only values whose clones are registered before the field walk begins (at + * tree-build time), so looking one up in the identity map yields the same answer regardless of + * the order fields happen to be walked in. + */ + static _isRemapType(value: any): boolean { + return value instanceof Entity || value instanceof Component; + } + + /** + * @internal + * Counted = the ReferResource family only. + */ + static _isReferenceResource(value: any): boolean { + return value instanceof ReferResource; + } + + /** + * @internal + * A deep-cloned instance without a compatible preset is constructed bare; name the contract + * when that fails instead of surfacing the constructor's raw error. + */ + static _bareConstruct(ctor: new () => any): any { + try { + return 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}` + ); + } + } +} diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 59e327b4bb..a2a1d9671a 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -1,5 +1,5 @@ import { Component } from "../Component"; -import { CloneManager } from "./CloneManager"; +import { CloneUtil } from "./CloneUtil"; import { CloneMode } from "./enums/CloneMode"; /** @@ -33,8 +33,8 @@ export class ComponentCloner { if (fieldMode === CloneMode.Ignore) continue; const sourceValue = source[k]; const preset = target[k]; - const cloned = (target[k] = CloneManager._cloneValue(sourceValue, preset, cloneMap, fieldMode)); - CloneManager._transferSlotOwnership(cloned, sourceValue, preset); + const cloned = (target[k] = CloneUtil._cloneValue(sourceValue, preset, cloneMap, fieldMode)); + CloneUtil._transferSlotOwnership(cloned, sourceValue, preset); } ((source as unknown))._cloneTo?.(target, cloneMap); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5811b591f..935675567d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,3 @@ -import "./clone/CloneDefaults"; - export { Platform } from "./Platform"; export { Engine } from "./Engine"; export { SystemInfo } from "./SystemInfo"; @@ -72,6 +70,7 @@ export * from "./env-probe/index"; export * from "./shader/index"; export * from "./Layer"; export { CloneManager, deepClone, assignmentClone, ignoreClone } from "./clone/CloneManager"; +export { CloneUtil } from "./clone/CloneUtil"; export * from "./renderingHardwareInterface/index"; export * from "./physics/index"; export * from "./Utils"; diff --git a/packages/core/src/particle/modules/CustomDataModule.ts b/packages/core/src/particle/modules/CustomDataModule.ts index 634faccde5..960822bcd8 100644 --- a/packages/core/src/particle/modules/CustomDataModule.ts +++ b/packages/core/src/particle/modules/CustomDataModule.ts @@ -1,5 +1,6 @@ import { Color, Vector4 } from "@galacean/engine-math"; -import { CloneManager, ignoreClone } from "../../clone/CloneManager"; +import { ignoreClone } from "../../clone/CloneManager"; +import { CloneUtil } from "../../clone/CloneUtil"; import { Logger } from "../../base/Logger"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -192,12 +193,12 @@ export class CustomDataModule extends ParticleGeneratorModule { _cloneTo(target: CustomDataModule, cloneMap: Map): void { for (const [name, curve] of this._curves) { const clonedCurve = new ParticleCompositeCurve(0); - CloneManager.deepCloneObject(curve, clonedCurve, cloneMap); + CloneUtil.deepCloneObject(curve, clonedCurve, cloneMap); target.addCurve(name, clonedCurve); } for (const [name, gradient] of this._gradients) { const clonedGradient = new ParticleCompositeGradient(new Color()); - CloneManager.deepCloneObject(gradient, clonedGradient, cloneMap); + CloneUtil.deepCloneObject(gradient, clonedGradient, cloneMap); target.addGradient(name, clonedGradient); } } diff --git a/packages/core/src/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts index a4011261ad..6d559afd00 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -2,7 +2,8 @@ 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"; @@ -610,7 +611,7 @@ export class ShaderData extends DataObject 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; diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 7e7b9e31df..dfba5c1635 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -262,6 +262,23 @@ class SharedDefaultTableScript extends Script { 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; @@ -1174,7 +1191,7 @@ describe("Clone remap", async () => { describe("deepCloneObject decorator awareness", () => { it("respects @ignoreClone on the source type's fields", async () => { - const { CloneManager } = await import("@galacean/engine-core"); + const { CloneUtil } = await import("@galacean/engine-core"); class Bag { kept = 1; @@ -1186,7 +1203,7 @@ describe("Clone remap", async () => { source.runtime = 42; const target = new Bag(); - CloneManager.deepCloneObject(source, target, new Map()); + CloneUtil.deepCloneObject(source, target, new Map()); expect(target.kept).eq(42); expect(target.runtime).eq(1); }); @@ -1294,6 +1311,49 @@ describe("Clone remap", async () => { 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", () => { From 9ee44e3046ca7545860c9fac29fead9b5b3a1194 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 18:46:37 +0800 Subject: [PATCH 071/109] refactor(clone): drop the unused CloneMode.Remap member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloneMode now describes only what a field decorator can request, and there is no @remapClone — remapping an Entity / Component reference is a type default executed by CloneUtil's instanceof branch, never routed through a mode. The member had no producer and no consumer left. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/enums/CloneMode.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index b02b1a73c7..f3a3b3938c 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -1,13 +1,11 @@ /** - * How a value is cloned, decided by the built-in default decision or a field decorator. + * How a field is cloned when a clone decorator overrides the built-in default for its type. */ export enum CloneMode { /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */ Ignore, /** Share the reference; a counted resource shared at a component's top-level slot is kept alive by the clone. */ Assignment, - /** Remap an Entity / Component reference to its clone within the cloned subtree. */ - Remap, /** * Recursively clone the graph structure (fresh containers/instances); each member follows its * own clone semantics — assets stay shared, entity refs remap, runtime state is ignored. From 7e46e9fa7259b76a5e6f40b59c80e8f6b4bf9553 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 18:53:59 +0800 Subject: [PATCH 072/109] refactor(clone): fold the deep-clone assertion back into the type branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _assertDeepCloneable existed only because the @deepClone rejection had to run before the dedup (behind it, an in-subtree Entity resolves to its clone and the misuse goes unnoticed), which put it on the far side of the instanceof branches and forced them to be repeated. Reordering removes the duplication instead: the engine-bound families produce no new object, so they need no dedup — an Entity's own map lookup IS the remap, and assets / runtime state never enter the map. Moving those three branches ahead of the dedup lets each own its rejection, and leaves the dedup covering exactly what it is for: the values below it that allocate a clone. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 88 ++++++++++++---------------- 1 file changed, 38 insertions(+), 50 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index a630075a0c..f6c3880ef8 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -65,14 +65,45 @@ export class CloneUtil { * and makes an engine-bound value throw instead of silently resolving to its default. */ static _cloneByType(value: any, reuse: any, cloneMap: Map, forceDeepClone = false): any { - // `@deepClone` on an engine-bound value is a mistake in the developer's intent — reject it - // before the dedup below, which would otherwise quietly resolve an in-subtree Entity to its - // clone and hide the misuse. - if (forceDeepClone) CloneUtil._assertDeepCloneable(value); + // Engine-bound families come first: none of them produces a new object, so they need no dedup + // — an Entity's own map lookup *is* the remap, and the others never enter the map. Being ahead + // of the dedup is also what lets `@deepClone` on one of them be rejected: behind it, an + // in-subtree Entity would resolve to its clone and the misuse would go unnoticed. + if (value instanceof Entity || value instanceof Component) { + if (forceDeepClone) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — Entity / Component ` + + `references are engine-bound. Remove @deepClone to remap the reference by default.` + ); + } + // In-subtree: the clone registered at tree-build time. Outside: keep the original reference. + return cloneMap.get(value) ?? value; + } + if (value instanceof ReferResource) { + if (forceDeepClone) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${value.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.` + ); + } + return value; + } + if ( + value instanceof UpdateFlagManager || + value instanceof UpdateFlag || + value instanceof DisorderedArray || + value instanceof SafeLoopArray + ) { + if (forceDeepClone) { + throw new Error( + `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — engine runtime state is ` + + `transient and belongs to the instance holding it. Remove @deepClone to keep the clone's own.` + ); + } + return reuse; + } - // Already produced for this source in the graph — a clone made earlier, or, for an Entity / - // Component inside the cloned subtree, its clone registered at tree-build time. Reusing it - // dedups shared / cyclic references and doubles as the in-subtree remap. + // Everything below produces a new object, so shared / cyclic references dedup here. const existing = cloneMap.get(value); if (existing) return existing; @@ -84,18 +115,6 @@ export class CloneUtil { return CloneUtil._deepCloneMap(value, reuse, cloneMap); } else if (value instanceof Set) { return CloneUtil._deepCloneSet(value, reuse, cloneMap); - } else if (value instanceof Entity || value instanceof Component) { - // Outside the cloned subtree (inside was returned by the dedup above): keep the original. - return value; - } else if (value instanceof ReferResource) { - return value; - } else if ( - value instanceof UpdateFlagManager || - value instanceof UpdateFlag || - value instanceof DisorderedArray || - value instanceof SafeLoopArray - ) { - return reuse; } else { // A non-container object. A compatible preset of the exact same type is reused as the clone // target; otherwise a bare instance is constructed (null-prototype objects have no @@ -249,37 +268,6 @@ export class CloneUtil { return dst; } - /** - * @internal - * A field decorator is the developer's explicit intent, so `@deepClone` on a value that can't be - * deep cloned is surfaced rather than silently resolved to that value's default behavior. - */ - static _assertDeepCloneable(value: any): void { - if (value instanceof Entity || value instanceof Component) { - throw new Error( - `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — Entity / Component ` + - `references are engine-bound. Remove @deepClone to remap the reference by default.` - ); - } - if (value instanceof ReferResource) { - throw new Error( - `CloneUtil: @deepClone cannot deep clone "${value.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 ( - value instanceof UpdateFlagManager || - value instanceof UpdateFlag || - value instanceof DisorderedArray || - value instanceof SafeLoopArray - ) { - throw new Error( - `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — engine runtime state is ` + - `transient and belongs to the instance holding it. Remove @deepClone to keep the clone's own.` - ); - } - } - /** * @internal * Remap types are the only values whose clones are registered before the field walk begins (at From 7248f0a6f307c94b5c9c0b8f5fb682d1e4770beb Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 18:58:04 +0800 Subject: [PATCH 073/109] refactor(clone): name CloneUtil parameters source / preset consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same two things went by different names across the file: the value being cloned was `source` in _cloneValue but `value` everywhere below it, and the clone's existing value at that position was `reuse`, which describes an intention rather than what it is. Both are now `source` / `preset`, matching the names ComponentCloner already uses at the call site. Left alone: _isRemapType / _isReferenceResource keep `value` — they are general predicates, called with a preset or a cloned value as often as a source; and deepCloneObject keeps `target`, which really is the write destination rather than a candidate to reuse. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 152 +++++++++++++-------------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index f6c3880ef8..4bbd1146fa 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -24,11 +24,11 @@ export class CloneUtil { * @internal * Clone gate for one value. */ - static _cloneValue(source: any, reuse: any, cloneMap: Map, fieldMode?: CloneMode): any { + static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { // A function is a value, not a graph: an explicit decorator shares the source function, while // the default keeps the clone's own constructor-rebound binding when it has one. if (typeof source === "function") { - return fieldMode === undefined && typeof reuse === "function" ? reuse : source; + return fieldMode === undefined && typeof preset === "function" ? preset : source; } if (source === null || typeof source !== "object") return source; @@ -37,9 +37,9 @@ export class CloneUtil { return source; case CloneMode.Deep: // `@deepClone` is an explicit intent: force a deep copy, and throw if it can't be honored. - return CloneUtil._cloneByType(source, reuse, cloneMap, true); + return CloneUtil._cloneByType(source, preset, cloneMap, true); default: - return CloneUtil._cloneByType(source, reuse, cloneMap); + return CloneUtil._cloneByType(source, preset, cloneMap); } } @@ -64,88 +64,88 @@ export class CloneUtil { * the one ambiguous case — a class with no deep-clone default — from "share" into "field-walk", * and makes an engine-bound value throw instead of silently resolving to its default. */ - static _cloneByType(value: any, reuse: any, cloneMap: Map, forceDeepClone = false): any { + static _cloneByType(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { // Engine-bound families come first: none of them produces a new object, so they need no dedup // — an Entity's own map lookup *is* the remap, and the others never enter the map. Being ahead // of the dedup is also what lets `@deepClone` on one of them be rejected: behind it, an // in-subtree Entity would resolve to its clone and the misuse would go unnoticed. - if (value instanceof Entity || value instanceof Component) { + if (source instanceof Entity || source instanceof Component) { if (forceDeepClone) { throw new Error( - `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — Entity / Component ` + + `CloneUtil: @deepClone cannot deep clone "${source.constructor.name}" — Entity / Component ` + `references are engine-bound. Remove @deepClone to remap the reference by default.` ); } // In-subtree: the clone registered at tree-build time. Outside: keep the original reference. - return cloneMap.get(value) ?? value; + return cloneMap.get(source) ?? source; } - if (value instanceof ReferResource) { + if (source instanceof ReferResource) { if (forceDeepClone) { throw new Error( - `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — assets are engine-bound ` + + `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.` ); } - return value; + return source; } if ( - value instanceof UpdateFlagManager || - value instanceof UpdateFlag || - value instanceof DisorderedArray || - value instanceof SafeLoopArray + source instanceof UpdateFlagManager || + source instanceof UpdateFlag || + source instanceof DisorderedArray || + source instanceof SafeLoopArray ) { if (forceDeepClone) { throw new Error( - `CloneUtil: @deepClone cannot deep clone "${value.constructor.name}" — engine runtime state is ` + + `CloneUtil: @deepClone cannot deep clone "${source.constructor.name}" — engine runtime state is ` + `transient and belongs to the instance holding it. Remove @deepClone to keep the clone's own.` ); } - return reuse; + return preset; } // Everything below produces a new object, so shared / cyclic references dedup here. - const existing = cloneMap.get(value); + const existing = cloneMap.get(source); if (existing) return existing; - if (ArrayBuffer.isView(value)) { - return CloneUtil._deepCloneArrayBuffer(value, reuse, cloneMap); - } else if (Array.isArray(value)) { - return CloneUtil._deepCloneArray(value, reuse, cloneMap); - } else if (value instanceof Map) { - return CloneUtil._deepCloneMap(value, reuse, cloneMap); - } else if (value instanceof Set) { - return CloneUtil._deepCloneSet(value, reuse, cloneMap); + if (ArrayBuffer.isView(source)) { + return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); + } else if (Array.isArray(source)) { + return CloneUtil._deepCloneArray(source, preset, cloneMap); + } else if (source instanceof Map) { + return CloneUtil._deepCloneMap(source, preset, cloneMap); + } else if (source instanceof Set) { + return CloneUtil._deepCloneSet(source, preset, cloneMap); } else { // A non-container object. A compatible preset of the exact same type is reused as the clone // target; otherwise a bare instance is constructed (null-prototype objects have no // constructor, so rebuild as such). - const ctor = (value).constructor; - const reusable = reuse && reuse !== value && reuse.constructor === ctor ? reuse : null; + const ctor = (source).constructor; + const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; // Math value type (Vector3, Color, ...) — a class instance carrying a callable copyFrom; copy // via it. Math cannot depend on core, so it cannot extend DataObject and is recognized this // way. Plain / null-prototype objects never take this branch, even when a `copyFrom` data // field rides in the payload. - if (ctor && ctor !== Object && typeof (value).copyFrom === "function") { + if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { const dst = (reusable ?? CloneUtil._bareConstruct(ctor)); - cloneMap.set(value, dst); - dst.copyFrom(value); - (value)._cloneTo?.(dst, cloneMap); + cloneMap.set(source, dst); + dst.copyFrom(source); + (source)._cloneTo?.(dst, cloneMap); return dst; } // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any // other class instance only when `@deepClone` forces it (the default shares it). - if (value instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { + if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); - cloneMap.set(value, dst); - CloneUtil.deepCloneObject(value, dst, cloneMap); - (value)._cloneTo?.(dst, cloneMap); + cloneMap.set(source, dst); + CloneUtil.deepCloneObject(source, dst, cloneMap); + (source)._cloneTo?.(dst, cloneMap); return dst; } // A class instance with no deep-clone default, on the default path — shared. - return value; + return source; } } @@ -155,7 +155,7 @@ export class CloneUtil { * account, a displaced owned counted preset releases one reference, and a slot sharing the * source's counted value acquires one. Destroy releases the slot (class contract). */ - static _transferSlotOwnership(cloned: any, sourceValue: any, preset: any): void { + static _transferSlotOwnership(cloned: any, source: any, preset: any): void { // Slot content unchanged (Ignore kept / value type copied in place / function reused). if (cloned === preset) return; if (CloneUtil._isReferenceResource(preset)) { @@ -170,7 +170,7 @@ export class CloneUtil { } // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path // returns a registered resource as-is), so it owns one reference. - if (cloned === sourceValue && CloneUtil._isReferenceResource(cloned)) { + if (cloned === source && CloneUtil._isReferenceResource(cloned)) { (cloned)._addReferCount(1); } } @@ -179,51 +179,51 @@ export class CloneUtil { * @internal * ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. */ - static _deepCloneArrayBuffer(value: ArrayBufferView, reuse: any, cloneMap: Map): ArrayBufferView { + static _deepCloneArrayBuffer(source: ArrayBufferView, preset: any, cloneMap: Map): ArrayBufferView { let dst: ArrayBufferView; - if (value instanceof DataView) { - if (reuse instanceof DataView && reuse !== value && reuse.byteLength === value.byteLength) { - new Uint8Array(reuse.buffer, reuse.byteOffset, reuse.byteLength).set( - new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + 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 = reuse; + dst = preset; } else { - dst = new DataView(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + dst = new DataView(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)); } } else { - const src = value; + const src = source; if ( - reuse && - reuse !== value && - reuse.constructor === src.constructor && - (reuse).length === src.length + preset && + preset !== source && + preset.constructor === src.constructor && + (preset).length === src.length ) { - (reuse).set(src); - dst = reuse; + (preset).set(src); + dst = preset; } else { dst = src.slice(); } } - cloneMap.set(value, dst); + cloneMap.set(source, dst); return dst; } /** * @internal * Array — every element re-enters the gate. A preset of the same type and length is filled in - * place; `reuse !== value` guards the case where the clone still shares the source's array + * place; `preset !== source` guards the case where the clone still shares the source's array * (a class-level default table), where writing into it would corrupt the source. */ - static _deepCloneArray(value: any[], reuse: any, cloneMap: Map): any[] { + static _deepCloneArray(source: any[], preset: any, cloneMap: Map): any[] { const dst = - reuse !== value && - Array.isArray(reuse) && - reuse.constructor === value.constructor && - reuse.length === value.length - ? reuse - : new Array(value.length); - cloneMap.set(value, dst); - for (let i = 0, n = value.length; i < n; i++) dst[i] = CloneUtil._cloneValue(value[i], undefined, cloneMap); + 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._cloneValue(source[i], undefined, cloneMap); return dst; } @@ -232,16 +232,16 @@ export class CloneUtil { * Map — every key and value re-enters the gate. A reused preset is cleared first: it holds the * clone's own constructor-built entries, which would otherwise survive alongside the source's. */ - static _deepCloneMap(value: Map, reuse: any, cloneMap: Map): Map { + static _deepCloneMap(source: Map, preset: any, cloneMap: Map): Map { let dst: Map; - if (reuse instanceof Map && reuse !== value && reuse.constructor === value.constructor) { - reuse.clear(); - dst = reuse; + if (preset instanceof Map && preset !== source && preset.constructor === source.constructor) { + preset.clear(); + dst = preset; } else { dst = new Map(); } - cloneMap.set(value, dst); - for (const entry of value) { + cloneMap.set(source, dst); + for (const entry of source) { dst.set( CloneUtil._cloneValue(entry[0], undefined, cloneMap), CloneUtil._cloneValue(entry[1], undefined, cloneMap) @@ -255,16 +255,16 @@ export class CloneUtil { * Set — every member re-enters the gate. A reused preset is cleared first, for the same reason * as `Map`. */ - static _deepCloneSet(value: Set, reuse: any, cloneMap: Map): Set { + static _deepCloneSet(source: Set, preset: any, cloneMap: Map): Set { let dst: Set; - if (reuse instanceof Set && reuse !== value && reuse.constructor === value.constructor) { - reuse.clear(); - dst = reuse; + if (preset instanceof Set && preset !== source && preset.constructor === source.constructor) { + preset.clear(); + dst = preset; } else { dst = new Set(); } - cloneMap.set(value, dst); - for (const v of value) dst.add(CloneUtil._cloneValue(v, undefined, cloneMap)); + cloneMap.set(source, dst); + for (const v of source) dst.add(CloneUtil._cloneValue(v, undefined, cloneMap)); return dst; } From 4f86f9fe6837662d927fb72828167bc2b7a8a1bf Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 19:06:02 +0800 Subject: [PATCH 074/109] refactor(clone): inline the remap-type check into Signal, drop the dead predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal was the only caller of _isRemapType, so the check moves to its one use site and both predicates go away (_isReferenceResource had already lost its callers when slot settlement switched to a direct instanceof). Signal's clone-time argument handling is unchanged: only Entity / Component references are looked up in the identity map — their clones are registered before the field walk, so the result cannot depend on field order — and every other argument is shared. Co-Authored-By: Claude Fable 5 --- packages/core/src/Signal.ts | 10 ++++++---- packages/core/src/clone/CloneUtil.ts | 26 ++++---------------------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index 555e755afa..dfe89cf862 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,6 +1,6 @@ import { DataObject } from "./base/DataObject"; import { Component } from "./Component"; -import { CloneUtil } from "./clone/CloneUtil"; +import { Entity } from "./Entity"; import { SafeLoopArray } from "./utils/SafeLoopArray"; /** @@ -148,9 +148,11 @@ export class Signal extends DataObject { const clonedArgs = new Array(len); for (let i = 0; i < len; i++) { const arg = args[i]; - // Only Entity/Component references (Remap types) are remapped; other objects are shared - // deterministically (looking them up in the identity map would depend on field-walk order). - clonedArgs[i] = CloneUtil._isRemapType(arg) ? (cloneMap.get(arg) ?? arg) : arg; + if (arg instanceof Entity || arg instanceof Component) { + clonedArgs[i] = cloneMap.get(arg) ?? arg; + } else { + clonedArgs[i] = arg; + } } return clonedArgs; } diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 4bbd1146fa..0f48f9367e 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -1,8 +1,8 @@ -import { ReferResource } from "../asset/ReferResource"; import { IReferable } from "../asset/IReferable"; -import { Logger } from "../base/Logger"; +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"; @@ -158,7 +158,7 @@ export class CloneUtil { static _transferSlotOwnership(cloned: any, source: any, preset: any): void { // Slot content unchanged (Ignore kept / value type copied in place / function reused). if (cloned === preset) return; - if (CloneUtil._isReferenceResource(preset)) { + if (preset instanceof ReferResource) { const presetRefCount = (<{ refCount?: number }>preset).refCount; presetRefCount !== undefined && presetRefCount <= 0 && @@ -170,7 +170,7 @@ export class CloneUtil { } // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path // returns a registered resource as-is), so it owns one reference. - if (cloned === source && CloneUtil._isReferenceResource(cloned)) { + if (cloned === source && cloned instanceof ReferResource) { (cloned)._addReferCount(1); } } @@ -268,24 +268,6 @@ export class CloneUtil { return dst; } - /** - * @internal - * Remap types are the only values whose clones are registered before the field walk begins (at - * tree-build time), so looking one up in the identity map yields the same answer regardless of - * the order fields happen to be walked in. - */ - static _isRemapType(value: any): boolean { - return value instanceof Entity || value instanceof Component; - } - - /** - * @internal - * Counted = the ReferResource family only. - */ - static _isReferenceResource(value: any): boolean { - return value instanceof ReferResource; - } - /** * @internal * A deep-cloned instance without a compatible preset is constructed bare; name the contract From 3a73a7c97731acad52b945c7d7bf824326a4478e Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 19:26:25 +0800 Subject: [PATCH 075/109] refactor(clone): rename _cloneByType to _cloneByDefault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old name described how the method classifies rather than what it is for, and "Type" oversold it — half of what it handles isn't a class at all (plain and null-prototype objects, arrays, buffer views). The new name states its role in the dispatch: _cloneValue applies an explicit field decorator, and everything else falls through to the built-in default, which is this. The forceDeepClone flag reads consistently under it — the default decision, plus one switch for the single ambiguous case. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 0f48f9367e..0db9a4001a 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -37,9 +37,9 @@ export class CloneUtil { return source; case CloneMode.Deep: // `@deepClone` is an explicit intent: force a deep copy, and throw if it can't be honored. - return CloneUtil._cloneByType(source, preset, cloneMap, true); + return CloneUtil._cloneByDefault(source, preset, cloneMap, true); default: - return CloneUtil._cloneByType(source, preset, cloneMap); + return CloneUtil._cloneByDefault(source, preset, cloneMap); } } @@ -60,11 +60,12 @@ export class CloneUtil { /** * @internal - * Identify a value by type family and clone it. `forceDeepClone` (a `@deepClone`'d field) turns - * the one ambiguous case — a class with no deep-clone default — from "share" into "field-walk", - * and makes an engine-bound value throw instead of silently resolving to its default. + * The built-in default: with no field decorator to follow, a value's own type family decides how + * it is cloned — identified and executed in one step. `forceDeepClone` (set by a `@deepClone`'d + * field) turns the one ambiguous case — a class with no deep-clone default — from "share" into + * "field-walk", and makes an engine-bound value throw instead of resolving to its default. */ - static _cloneByType(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { + static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { // Engine-bound families come first: none of them produces a new object, so they need no dedup // — an Entity's own map lookup *is* the remap, and the others never enter the map. Being ahead // of the dedup is also what lets `@deepClone` on one of them be rejected: behind it, an From 55f0009af19a772027f94a888a853b1058edd2c3 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 21:25:42 +0800 Subject: [PATCH 076/109] fix(core): type UpdateFlagManager dispatch param as unknown, not object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR narrowed the param type from `Object` to `object` while cleaning up the clone system, which broke the callers that pass a primitive: Skin dispatches a bone count (number), and SkinnedMeshRenderer's listener is typed to receive `number | Entity` — three type errors that had been failing b:types since. `unknown` is the accurate type: the param is an opaque payload the dispatcher never inspects, and callers legitimately pass both objects (an Entity, `this`) and primitives. Reverting to `Object` would have hidden the mismatch instead — it accepts anything, which is why the callers type-checked before. b:types is now clean (was 3 errors), 1592/1592. Co-Authored-By: Claude Fable 5 --- packages/core/src/UpdateFlag.ts | 2 +- packages/core/src/UpdateFlagManager.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/UpdateFlag.ts b/packages/core/src/UpdateFlag.ts index 4c8ca39806..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 92675f5210..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; From 62b9a3bf095636d1e3db1c92d4cb6a9b931d6b05 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 21:40:04 +0800 Subject: [PATCH 077/109] fix(clone): dedup per producing branch, not before the type dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity-map lookup sat ahead of the type dispatch, but whether a value produces a clone at all depends on forceDeepClone: a class with no deep-clone default is shared on the default path and only field-walked when @deepClone forces it. Sharing produces nothing, so such a value has no business consulting the map — yet it did, and would take whatever a @deepClone'd field had already put there. Two fields on one component pointing at the same default-less instance, one decorated and one not, therefore disagreed about that instance based on which was walked first: with @deepClone declared first, the plain field silently received the decorated field's copy instead of sharing the original; declared last, both behaved correctly. The lookup now lives in each branch that actually produces a clone (the four container helpers, the copyFrom path, the field-walk path), pairing it with the cloneMap.set it belongs to — dedup covers exactly the values that get cloned, and each field's own intent is honored regardless of declaration order. Cycle and aliasing behavior is unchanged: everything that enters the map still checks it first. Both orderings pinned by tests (the first failed before this change). 1594/1594, b:types clean, benchmark within noise (131us particle / 2905us tree). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 24 ++++++++++--- tests/src/core/CloneManager.test.ts | 51 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 0db9a4001a..85a77c7bb1 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -104,10 +104,10 @@ export class CloneUtil { return preset; } - // Everything below produces a new object, so shared / cyclic references dedup here. - const existing = cloneMap.get(source); - if (existing) return existing; - + // No dedup here: whether a value produces a clone at all depends on `forceDeepClone` (a + // default-less class is shared by default, cloned when forced), so each branch that actually + // produces one dedups for itself. Looking up first would let a `@deepClone`'d field's copy be + // handed to a plain field that asked to share — and only when it happened to be walked first. if (ArrayBuffer.isView(source)) { return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); } else if (Array.isArray(source)) { @@ -128,6 +128,8 @@ export class CloneUtil { // way. Plain / null-prototype objects never take this branch, even when a `copyFrom` data // field rides in the payload. if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { + const existing = cloneMap.get(source); + if (existing) return existing; const dst = (reusable ?? CloneUtil._bareConstruct(ctor)); cloneMap.set(source, dst); dst.copyFrom(source); @@ -138,6 +140,8 @@ export class CloneUtil { // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any // other class instance only when `@deepClone` forces it (the default shares it). if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { + const existing = cloneMap.get(source); + if (existing) return existing; const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); cloneMap.set(source, dst); CloneUtil.deepCloneObject(source, dst, cloneMap); @@ -181,6 +185,9 @@ export class CloneUtil { * ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. */ 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) { @@ -216,6 +223,9 @@ export class CloneUtil { * (a class-level default table), where writing into it would corrupt the source. */ static _deepCloneArray(source: any[], preset: any, cloneMap: Map): any[] { + const existing = cloneMap.get(source); + if (existing) return existing; + const dst = preset !== source && Array.isArray(preset) && @@ -234,6 +244,9 @@ export class CloneUtil { * clone's own constructor-built entries, which would otherwise survive alongside the source's. */ static _deepCloneMap(source: Map, preset: any, cloneMap: Map): 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(); @@ -257,6 +270,9 @@ export class CloneUtil { * as `Map`. */ static _deepCloneSet(source: Set, preset: any, cloneMap: Map): 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(); diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index dfba5c1635..fcb50dad3e 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -291,6 +291,20 @@ class ForcedDeepScript extends Script { 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; @@ -672,6 +686,43 @@ describe("Clone remap", async () => { 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"); From 5509233d4dc63a665fbe42e47c8ee6a53f8a38fb Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 22:01:34 +0800 Subject: [PATCH 078/109] refactor(clone): flatten _cloneByDefault into one dispatch chain With the dedup pushed into the branches that produce a clone, nothing shared remains between them, so the two styles the function had grown (standalone `if ... return` for the engine-bound families, an `else if` chain plus a trailing `else` block for the rest) collapse into one flat sequence of guard clauses. Every branch already returns, so the `else` keywords were dead weight and the final block cost a level of indent for no reason. Pure formatting: 1594/1594. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 75 +++++++++++++--------------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 85a77c7bb1..072dfbe8df 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -108,50 +108,45 @@ export class CloneUtil { // default-less class is shared by default, cloned when forced), so each branch that actually // produces one dedups for itself. Looking up first would let a `@deepClone`'d field's copy be // handed to a plain field that asked to share — and only when it happened to be walked first. - if (ArrayBuffer.isView(source)) { - return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); - } else if (Array.isArray(source)) { - return CloneUtil._deepCloneArray(source, preset, cloneMap); - } else if (source instanceof Map) { - return CloneUtil._deepCloneMap(source, preset, cloneMap); - } else if (source instanceof Set) { - return CloneUtil._deepCloneSet(source, preset, cloneMap); - } else { - // A non-container object. A compatible preset of the exact same type is reused as the clone - // target; otherwise a bare instance is constructed (null-prototype objects have no - // constructor, so rebuild as such). - const ctor = (source).constructor; - const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; + if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); + if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap); + if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap); + if (source instanceof Set) return CloneUtil._deepCloneSet(source, preset, cloneMap); - // Math value type (Vector3, Color, ...) — a class instance carrying a callable copyFrom; copy - // via it. Math cannot depend on core, so it cannot extend DataObject and is recognized this - // way. Plain / null-prototype objects never take this branch, even when a `copyFrom` data - // field rides in the payload. - if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { - const existing = cloneMap.get(source); - if (existing) return existing; - const dst = (reusable ?? CloneUtil._bareConstruct(ctor)); - cloneMap.set(source, dst); - dst.copyFrom(source); - (source)._cloneTo?.(dst, cloneMap); - return dst; - } + // A non-container object. A compatible preset of the exact same type is reused as the clone + // target; otherwise a bare instance is constructed (null-prototype objects have no + // constructor, so rebuild as such). + const ctor = (source).constructor; + const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; - // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any - // other class instance only when `@deepClone` forces it (the default shares it). - if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { - const existing = cloneMap.get(source); - if (existing) return existing; - const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); - cloneMap.set(source, dst); - CloneUtil.deepCloneObject(source, dst, cloneMap); - (source)._cloneTo?.(dst, cloneMap); - return dst; - } + // Math value type (Vector3, Color, ...) — a class instance carrying a callable copyFrom; copy + // via it. Math cannot depend on core, so it cannot extend DataObject and is recognized this + // way. Plain / null-prototype objects never take this branch, even when a `copyFrom` data + // field rides in the payload. + if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { + const existing = cloneMap.get(source); + if (existing) return existing; + const dst = (reusable ?? CloneUtil._bareConstruct(ctor)); + cloneMap.set(source, dst); + dst.copyFrom(source); + (source)._cloneTo?.(dst, cloneMap); + return dst; + } - // A class instance with no deep-clone default, on the default path — shared. - return source; + // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any + // other class instance only when `@deepClone` forces it (the default shares it). + if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { + const existing = cloneMap.get(source); + if (existing) return existing; + const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); + cloneMap.set(source, dst); + CloneUtil.deepCloneObject(source, dst, cloneMap); + (source)._cloneTo?.(dst, cloneMap); + return dst; } + + // A class instance with no deep-clone default, on the default path — shared. + return source; } /** From 09fdbd5598bafb32412e44f9d78bbed14cec3077 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 22:15:43 +0800 Subject: [PATCH 079/109] docs(clone): strip restating JSDoc from internal methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal methods here follow the engine convention of a bare `/** @internal */` (as Component's _onAwake / _setActive do) — a prose summary of what the code plainly says earns nothing. What survives is the handful of comments that record a constraint the code cannot show: why a preset must not be filled when it still aliases the source, why a reused Map/Set is cleared first, and what forceDeepClone changes. Those were each learned from a failing test, so they stay — moved inline, next to the line they constrain. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 11 ++--- packages/core/src/clone/CloneUtil.ts | 60 +++++++------------------ 2 files changed, 20 insertions(+), 51 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 6bb0df223f..c398d88301 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -32,14 +32,11 @@ export function ignoreClone(target: object, propertyKey: string): void { * `extends` chains. The cloning itself lives in `CloneUtil`. */ export class CloneManager { - /** - * @internal - * Register a field-level clone mode (highest priority — overrides the built-in default decision). - * Stamped on the class's own `_fieldModes`, prototypally chained to the parent class's — native - * lookup resolves inheritance for free (a subclass re-decorating the same field name shadows the - * ancestor's), no separate registry or cache to keep in sync. - */ + /** @internal */ static _registerFieldMode(target: any, propertyKey: string, mode: CloneMode): void { + // Each class gets its own `_fieldModes`, prototypally chained to its parent's, so native + // lookup resolves inheritance (a subclass re-decorating a field shadows the ancestor's) with + // no registry or cache to keep in sync. if (!Object.prototype.hasOwnProperty.call(target, "_fieldModes")) { Object.defineProperty(target, "_fieldModes", { value: Object.create(target._fieldModes ?? null), diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 072dfbe8df..2163af704b 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -20,10 +20,7 @@ import { CloneMode } from "./enums/CloneMode"; * class pulls in while still being defined. */ export class CloneUtil { - /** - * @internal - * Clone gate for one value. - */ + /** @internal */ static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { // A function is a value, not a graph: an explicit decorator shares the source function, while // the default keeps the clone's own constructor-rebound binding when it has one. @@ -43,11 +40,7 @@ export class CloneUtil { } } - /** - * @internal - * Clone all enumerable fields of source into target; each field goes back through the gate, - * honoring field-level decorators. - */ + /** @internal */ static deepCloneObject(source: any, target: object, cloneMap: Map): void { // Resolved once per object (a single prototype-chain walk), not once per field. const fieldModes = source._fieldModes; @@ -60,10 +53,9 @@ export class CloneUtil { /** * @internal - * The built-in default: with no field decorator to follow, a value's own type family decides how - * it is cloned — identified and executed in one step. `forceDeepClone` (set by a `@deepClone`'d - * field) turns the one ambiguous case — a class with no deep-clone default — from "share" into - * "field-walk", and makes an engine-bound value throw instead of resolving to its default. + * `forceDeepClone` (a `@deepClone`'d field) flips a class with no deep-clone default from + * "share" to "field-walk", and makes an engine-bound value throw instead of resolving to its + * default. */ static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { // Engine-bound families come first: none of them produces a new object, so they need no dedup @@ -149,12 +141,7 @@ export class CloneUtil { return source; } - /** - * @internal - * Settle a slot's counted ownership after the gate wrote it: an unchanged slot keeps its - * account, a displaced owned counted preset releases one reference, and a slot sharing the - * source's counted value acquires one. Destroy releases the slot (class contract). - */ + /** @internal */ static _transferSlotOwnership(cloned: any, source: any, preset: any): void { // Slot content unchanged (Ignore kept / value type copied in place / function reused). if (cloned === preset) return; @@ -175,10 +162,7 @@ export class CloneUtil { } } - /** - * @internal - * ArrayBuffer view — byte copy into a reused view of matching layout, else a fresh one. - */ + /** @internal */ static _deepCloneArrayBuffer(source: ArrayBufferView, preset: any, cloneMap: Map): ArrayBufferView { const existing = cloneMap.get(source); if (existing) return existing; @@ -211,16 +195,13 @@ export class CloneUtil { return dst; } - /** - * @internal - * Array — every element re-enters the gate. A preset of the same type and length is filled in - * place; `preset !== source` guards the case where the clone still shares the source's array - * (a class-level default table), where writing into it would corrupt the source. - */ + /** @internal */ static _deepCloneArray(source: any[], preset: any, cloneMap: Map): any[] { const existing = cloneMap.get(source); if (existing) return existing; + // `preset !== source`: the clone may still alias the source's array (a class-level default + // table), and filling that in place would write through into the source. const dst = preset !== source && Array.isArray(preset) && @@ -233,17 +214,15 @@ export class CloneUtil { return dst; } - /** - * @internal - * Map — every key and value re-enters the gate. A reused preset is cleared first: it holds the - * clone's own constructor-built entries, which would otherwise survive alongside the source's. - */ + /** @internal */ static _deepCloneMap(source: Map, preset: any, cloneMap: Map): Map { const existing = cloneMap.get(source); if (existing) return >existing; let dst: Map; if (preset instanceof Map && preset !== source && preset.constructor === source.constructor) { + // Clear before refilling: the preset holds the clone's own constructor-built entries, which + // would otherwise survive alongside the source's. preset.clear(); dst = preset; } else { @@ -259,17 +238,14 @@ export class CloneUtil { return dst; } - /** - * @internal - * Set — every member re-enters the gate. A reused preset is cleared first, for the same reason - * as `Map`. - */ + /** @internal */ static _deepCloneSet(source: Set, preset: any, cloneMap: Map): Set { const existing = cloneMap.get(source); if (existing) return >existing; let dst: Set; if (preset instanceof Set && preset !== source && preset.constructor === source.constructor) { + // Cleared before refilling, same as `Map`. preset.clear(); dst = preset; } else { @@ -280,11 +256,7 @@ export class CloneUtil { return dst; } - /** - * @internal - * A deep-cloned instance without a compatible preset is constructed bare; name the contract - * when that fails instead of surfacing the constructor's raw error. - */ + /** @internal */ static _bareConstruct(ctor: new () => any): any { try { return new ctor(); From f6ee7290424150cc04376f5c1df95ab7c233bec5 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 22:17:50 +0800 Subject: [PATCH 080/109] style(clone): use the multi-line @internal block on methods Matches the engine convention (Component._onAwake and friends): methods carry the expanded /** @internal */ block, the single-line form is for fields. Nine methods were collapsed to the single-line form in the previous commit; this restores the house style. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneManager.ts | 4 +++- packages/core/src/clone/CloneUtil.ts | 32 ++++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index c398d88301..659f56b60b 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -32,7 +32,9 @@ export function ignoreClone(target: object, propertyKey: string): void { * `extends` chains. The cloning itself lives in `CloneUtil`. */ export class CloneManager { - /** @internal */ + /** + * @internal + */ static _registerFieldMode(target: any, propertyKey: string, mode: CloneMode): void { // Each class gets its own `_fieldModes`, prototypally chained to its parent's, so native // lookup resolves inheritance (a subclass re-decorating a field shadows the ancestor's) with diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 2163af704b..bccadd709e 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -20,7 +20,9 @@ import { CloneMode } from "./enums/CloneMode"; * class pulls in while still being defined. */ export class CloneUtil { - /** @internal */ + /** + * @internal + */ static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { // A function is a value, not a graph: an explicit decorator shares the source function, while // the default keeps the clone's own constructor-rebound binding when it has one. @@ -40,7 +42,9 @@ export class CloneUtil { } } - /** @internal */ + /** + * @internal + */ static deepCloneObject(source: any, target: object, cloneMap: Map): void { // Resolved once per object (a single prototype-chain walk), not once per field. const fieldModes = source._fieldModes; @@ -141,7 +145,9 @@ export class CloneUtil { return source; } - /** @internal */ + /** + * @internal + */ static _transferSlotOwnership(cloned: any, source: any, preset: any): void { // Slot content unchanged (Ignore kept / value type copied in place / function reused). if (cloned === preset) return; @@ -162,7 +168,9 @@ export class CloneUtil { } } - /** @internal */ + /** + * @internal + */ static _deepCloneArrayBuffer(source: ArrayBufferView, preset: any, cloneMap: Map): ArrayBufferView { const existing = cloneMap.get(source); if (existing) return existing; @@ -195,7 +203,9 @@ export class CloneUtil { return dst; } - /** @internal */ + /** + * @internal + */ static _deepCloneArray(source: any[], preset: any, cloneMap: Map): any[] { const existing = cloneMap.get(source); if (existing) return existing; @@ -214,7 +224,9 @@ export class CloneUtil { return dst; } - /** @internal */ + /** + * @internal + */ static _deepCloneMap(source: Map, preset: any, cloneMap: Map): Map { const existing = cloneMap.get(source); if (existing) return >existing; @@ -238,7 +250,9 @@ export class CloneUtil { return dst; } - /** @internal */ + /** + * @internal + */ static _deepCloneSet(source: Set, preset: any, cloneMap: Map): Set { const existing = cloneMap.get(source); if (existing) return >existing; @@ -256,7 +270,9 @@ export class CloneUtil { return dst; } - /** @internal */ + /** + * @internal + */ static _bareConstruct(ctor: new () => any): any { try { return new ctor(); From cdb0d1b52840397a1df9cb30b2e05528d32599c1 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 22:28:19 +0800 Subject: [PATCH 081/109] refactor(clone): prefix deepCloneObject with an underscore It carries @internal like every other method on CloneUtil but was the only one without the underscore marking it as such. Renamed at its five call sites (ShaderData, CustomDataModule, the field-walk branch, and the test). Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 4 ++-- packages/core/src/particle/modules/CustomDataModule.ts | 4 ++-- packages/core/src/shader/ShaderData.ts | 2 +- tests/src/core/CloneManager.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index bccadd709e..e5661ffa66 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -45,7 +45,7 @@ export class CloneUtil { /** * @internal */ - static deepCloneObject(source: any, target: object, cloneMap: Map): void { + static _deepCloneObject(source: any, target: object, cloneMap: Map): void { // Resolved once per object (a single prototype-chain walk), not once per field. const fieldModes = source._fieldModes; for (const k in source) { @@ -136,7 +136,7 @@ export class CloneUtil { if (existing) return existing; const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); cloneMap.set(source, dst); - CloneUtil.deepCloneObject(source, dst, cloneMap); + CloneUtil._deepCloneObject(source, dst, cloneMap); (source)._cloneTo?.(dst, cloneMap); return dst; } diff --git a/packages/core/src/particle/modules/CustomDataModule.ts b/packages/core/src/particle/modules/CustomDataModule.ts index 960822bcd8..a69a9659d8 100644 --- a/packages/core/src/particle/modules/CustomDataModule.ts +++ b/packages/core/src/particle/modules/CustomDataModule.ts @@ -193,12 +193,12 @@ export class CustomDataModule extends ParticleGeneratorModule { _cloneTo(target: CustomDataModule, cloneMap: Map): void { for (const [name, curve] of this._curves) { const clonedCurve = new ParticleCompositeCurve(0); - CloneUtil.deepCloneObject(curve, clonedCurve, cloneMap); + CloneUtil._deepCloneObject(curve, clonedCurve, cloneMap); target.addCurve(name, clonedCurve); } for (const [name, gradient] of this._gradients) { const clonedGradient = new ParticleCompositeGradient(new Color()); - CloneUtil.deepCloneObject(gradient, clonedGradient, cloneMap); + CloneUtil._deepCloneObject(gradient, clonedGradient, cloneMap); target.addGradient(name, clonedGradient); } } diff --git a/packages/core/src/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts index 6d559afd00..5238904489 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -611,7 +611,7 @@ export class ShaderData extends DataObject implements IReferable, IClone { } cloneTo(target: ShaderData): void { - CloneUtil.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; diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index fcb50dad3e..a0e49bdb2d 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1240,7 +1240,7 @@ describe("Clone remap", async () => { }); }); - describe("deepCloneObject decorator awareness", () => { + describe("_deepCloneObject decorator awareness", () => { it("respects @ignoreClone on the source type's fields", async () => { const { CloneUtil } = await import("@galacean/engine-core"); @@ -1254,7 +1254,7 @@ describe("Clone remap", async () => { source.runtime = 42; const target = new Bag(); - CloneUtil.deepCloneObject(source, target, new Map()); + CloneUtil._deepCloneObject(source, target, new Map()); expect(target.kept).eq(42); expect(target.runtime).eq(1); }); From 6d208622adfa74db470e062c13f52bd097b6b55a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 22:34:19 +0800 Subject: [PATCH 082/109] fix(clone): check the function branch before the non-object guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the function handling into _cloneByDefault placed it after `typeof source !== "object"`, which is true for functions — so the branch became unreachable and every function field fell through to plain sharing, losing "the default keeps the clone's own constructor-rebound binding" (the BoundHandlerScript test caught it). The check now runs ahead of that guard, and carries the decorator distinction the old fieldMode gave it: forced deep (an explicit decorator) shares the source function, the default prefers the clone's own bound preset. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 37 +++++++++++++--------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index e5661ffa66..749bb62fee 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -23,14 +23,20 @@ export class CloneUtil { /** * @internal */ - static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { - // A function is a value, not a graph: an explicit decorator shares the source function, while - // the default keeps the clone's own constructor-rebound binding when it has one. - if (typeof source === "function") { - return fieldMode === undefined && typeof preset === "function" ? preset : source; + static _deepCloneObject(source: any, target: object, cloneMap: Map): void { + // Resolved once per object (a single prototype-chain walk), not once per field. + const fieldModes = source._fieldModes; + for (const k in source) { + const fieldMode = fieldModes?.[k]; + if (fieldMode === CloneMode.Ignore) continue; + target[k] = CloneUtil._cloneValue(source[k], target[k], cloneMap, fieldMode); } - if (source === null || typeof source !== "object") return source; + } + /** + * @internal + */ + static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { switch (fieldMode) { case CloneMode.Assignment: return source; @@ -42,19 +48,6 @@ export class CloneUtil { } } - /** - * @internal - */ - static _deepCloneObject(source: any, target: object, cloneMap: Map): void { - // Resolved once per object (a single prototype-chain walk), not once per field. - const fieldModes = source._fieldModes; - for (const k in source) { - const fieldMode = fieldModes?.[k]; - if (fieldMode === CloneMode.Ignore) continue; - target[k] = CloneUtil._cloneValue(source[k], target[k], cloneMap, fieldMode); - } - } - /** * @internal * `forceDeepClone` (a `@deepClone`'d field) flips a class with no deep-clone default from @@ -62,6 +55,11 @@ export class CloneUtil { * default. */ static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { + // A function is a value, not a graph: an explicit decorator shares the source function, while + // the default keeps the clone's own constructor-rebound binding when it has one. Checked before + // the non-object guard below, which `typeof fn !== "object"` would otherwise swallow. + if (typeof source === "function") return forceDeepClone ? source : typeof preset === "function" ? preset : source; + if (source === null || typeof source !== "object") return source; // Engine-bound families come first: none of them produces a new object, so they need no dedup // — an Entity's own map lookup *is* the remap, and the others never enter the map. Being ahead // of the dedup is also what lets `@deepClone` on one of them be rejected: behind it, an @@ -99,7 +97,6 @@ export class CloneUtil { } return preset; } - // No dedup here: whether a value produces a clone at all depends on `forceDeepClone` (a // default-less class is shared by default, cloned when forced), so each branch that actually // produces one dedups for itself. Looking up first would let a `@deepClone`'d field's copy be From 7a1be3d5c18c445f1dfeaf2cdf91a0239544c808 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 22:38:04 +0800 Subject: [PATCH 083/109] refactor(clone): replace the fieldMode switch with a guard and a derived flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch's `default` covered two unrelated cases — no decorator, and `Ignore` — hiding the fact that Ignore never reaches this function (the field walk skips those fields before calling in), and reading as though @ignoreClone fell through to the default deep clone. It also spelled out the same _cloneByDefault call twice just to vary one boolean. An early return for Assignment plus `fieldMode === CloneMode.Deep` as the forceDeepClone argument says the same thing in three lines, and the Ignore invariant is now stated instead of implied. Co-Authored-By: Claude Fable 5 --- packages/core/src/clone/CloneUtil.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 749bb62fee..85e73c7b54 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -37,15 +37,10 @@ export class CloneUtil { * @internal */ static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { - switch (fieldMode) { - case CloneMode.Assignment: - return source; - case CloneMode.Deep: - // `@deepClone` is an explicit intent: force a deep copy, and throw if it can't be honored. - return CloneUtil._cloneByDefault(source, preset, cloneMap, true); - default: - return CloneUtil._cloneByDefault(source, preset, cloneMap); - } + if (fieldMode === CloneMode.Assignment) return source; + // `@deepClone` is an explicit intent: force a deep copy, and throw if it can't be honored. + // `Ignore` never arrives here — the field walk skips those fields before calling in. + return CloneUtil._cloneByDefault(source, preset, cloneMap, fieldMode === CloneMode.Deep); } /** From 08bab73e4556fd74463ea84c080b042479642ef4 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 22:50:33 +0800 Subject: [PATCH 084/109] test(clone): reach the field walk through the public API, unexport CloneUtil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups: - Two fixture comments still described the pre-throw behavior ("must fall back to remap / sharing") while their tests assert a throw. - The @ignoreClone-during-field-walk test called CloneUtil._deepCloneObject directly, which was the sole reason CloneUtil — a class that is @internal top to bottom, with no cross-package consumer — appeared in the public barrel. Driving the same behavior through @deepClone on a plain class exercises the identical path from the public API, so the export is withdrawn. Reverse-verified: dropping @ignoreClone from the walked class fails the test. 1594/1594, b:types clean. Co-Authored-By: Claude Fable 5 --- packages/core/src/index.ts | 1 - tests/src/core/CloneManager.test.ts | 51 +++++++++++++++++++---------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 935675567d..e3e1f6b3b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,7 +70,6 @@ export * from "./env-probe/index"; export * from "./shader/index"; export * from "./Layer"; export { CloneManager, deepClone, assignmentClone, ignoreClone } from "./clone/CloneManager"; -export { CloneUtil } from "./clone/CloneUtil"; export * from "./renderingHardwareInterface/index"; export * from "./physics/index"; export * from "./Utils"; diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index a0e49bdb2d..410ba52726 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -285,6 +285,19 @@ class PlainConfig { nested = { x: 1 }; } +/** 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 @@ -310,13 +323,13 @@ class SharedPlainScript extends Script { config: PlainConfig = null; } -/** Script misusing @deepClone on an Entity ref (must fall back to remap, never construct) */ +/** 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 (must fall back to sharing, never construct) */ +/** Script misusing @deepClone on an engine-bound asset — cloning it must throw. */ class DeepAssetRefScript extends Script { @deepClone texture: Texture2D; @@ -1240,23 +1253,25 @@ describe("Clone remap", async () => { }); }); - describe("_deepCloneObject decorator awareness", () => { - it("respects @ignoreClone on the source type's fields", async () => { - const { CloneUtil } = await import("@galacean/engine-core"); + 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; - class Bag { - kept = 1; - @ignoreClone - runtime = 1; - } - const source = new Bag(); - source.kept = 42; - source.runtime = 42; - const target = new Bag(); - - CloneUtil._deepCloneObject(source, target, new Map()); - expect(target.kept).eq(42); - expect(target.runtime).eq(1); + 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(); }); }); From e663cb227ee1893ecd64079452058a0bcfccd21c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 23:01:58 +0800 Subject: [PATCH 085/109] refactor(particle): let the gate clone CustomDataModule's streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _cloneTo existed because the maps and their stream caches were all @ignoreClone, so the module had to rebuild them by hand: construct each entry with a placeholder (`new ParticleCompositeCurve(0)`), field-walk the source over it, then replay addCurve / addGradient to re-derive the caches — re-validating names and re-resolving ShaderProperties that the source had already resolved. None of that is needed now. Dropping @ignoreClone from the four fields lets the gate deep-clone the maps and the stream arrays directly, and the identity map lands each stream's `curve` / `gradient` on the same clone the map holds — the aliasing addCurve used to maintain by passing one reference to both. A ShaderProperty is a plain class with no deep-clone default, so it stays shared, which is what a globally registered property must be. Added the aliasing assertion the existing clone test was missing (a stream pointing at a second copy would silently stop tracking edits made through the map), and updated its comment, which still described the old hook. 1594/1594, b:types clean. Co-Authored-By: Claude Fable 5 --- .../src/particle/modules/CustomDataModule.ts | 25 +++---------------- tests/src/core/particle/CustomData.test.ts | 14 +++++++---- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/packages/core/src/particle/modules/CustomDataModule.ts b/packages/core/src/particle/modules/CustomDataModule.ts index a69a9659d8..94d753f3fc 100644 --- a/packages/core/src/particle/modules/CustomDataModule.ts +++ b/packages/core/src/particle/modules/CustomDataModule.ts @@ -1,6 +1,4 @@ import { Color, Vector4 } from "@galacean/engine-math"; -import { ignoreClone } from "../../clone/CloneManager"; -import { CloneUtil } from "../../clone/CloneUtil"; import { Logger } from "../../base/Logger"; import { ShaderData } from "../../shader/ShaderData"; import { ShaderProperty } from "../../shader/ShaderProperty"; @@ -54,14 +52,13 @@ 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 + // Cloned by the gate alongside `_curves` / `_gradients`: the identity map makes each stream's + // `curve` / `gradient` land on the same clone the map holds, and a `ShaderProperty` — a plain + // class registered globally by name — is shared rather than copied. private _curveStreams: CurveStream[] = []; - @ignoreClone private _gradientStreams: GradientStream[] = []; /** @@ -187,22 +184,6 @@ export class CustomDataModule extends ParticleGeneratorModule { this._gradients.delete(name); } - /** - * @internal - */ - _cloneTo(target: CustomDataModule, cloneMap: Map): void { - for (const [name, curve] of this._curves) { - const clonedCurve = new ParticleCompositeCurve(0); - CloneUtil._deepCloneObject(curve, clonedCurve, cloneMap); - target.addCurve(name, clonedCurve); - } - for (const [name, gradient] of this._gradients) { - const clonedGradient = new ParticleCompositeGradient(new Color()); - CloneUtil._deepCloneObject(gradient, clonedGradient, cloneMap); - target.addGradient(name, clonedGradient); - } - } - /** * @internal */ diff --git a/tests/src/core/particle/CustomData.test.ts b/tests/src/core/particle/CustomData.test.ts index 540f3b0461..60bd6c420d 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); @@ -342,6 +340,12 @@ describe("CustomDataModule", function () { expect(clonedCurveStreams.map((s) => s.name)).to.deep.eq(["Intensity"]); expect(clonedGradientStreams.map((s) => s.name)).to.deep.eq(["Tint"]); + // Each stream points at the very object its map holds — the identity map collapses both + // references onto one clone. Were they two separate copies, editing the map entry would + // silently stop reaching the shader. + expect((clonedCurveStreams[0] as any).curve).to.eq(clonedCustomData.curves.get("Intensity")); + expect((clonedGradientStreams[0] as any).gradient).to.eq(clonedCustomData.gradients.get("Tint")); + // Mutation isolation: bumping the clone does not bleed back into the source. clonedCustomData.curves.get("Intensity")!.constantMax = 0.1; expect(sourceCustomData.curves.get("Intensity")!.constantMax).to.eq(0.8); From 44756d8f18b134ef6d6964959c89b55c2abfbacc Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 23:08:45 +0800 Subject: [PATCH 086/109] test(particle): assert cloned custom-data streams field by field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clone test only checked stream names, which said nothing about whether the gate reproduces what addCurve / addGradient used to build. It now compares every field against the source's, each with the comparison its role demands: lastMode by value, ShaderProperty by identity (a globally registered instance must be shared, and a structurally identical copy would slip past deep equality while breaking uniform lookup), keysCountCache by value but required to be a distinct vector, and the stream's curve/gradient required to be the same object its own map holds while differing from the source's. Verified against both implementations before committing: the field dump is byte-identical whether the clone comes from the removed _cloneTo hook or from the gate. Reverse-verified too — re-marking the stream arrays @ignoreClone empties them, and making ShaderProperty deep-cloneable trips the identity assertion. Co-Authored-By: Claude Fable 5 --- tests/src/core/particle/CustomData.test.ts | 50 ++++++++++++++++------ 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/tests/src/core/particle/CustomData.test.ts b/tests/src/core/particle/CustomData.test.ts index 60bd6c420d..c2d9180abb 100644 --- a/tests/src/core/particle/CustomData.test.ts +++ b/tests/src/core/particle/CustomData.test.ts @@ -332,19 +332,43 @@ 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"]); - - // Each stream points at the very object its map holds — the identity map collapses both - // references onto one clone. Were they two separate copies, editing the map entry would - // silently stop reaching the shader. - expect((clonedCurveStreams[0] as any).curve).to.eq(clonedCustomData.curves.get("Intensity")); - expect((clonedGradientStreams[0] as any).gradient).to.eq(clonedCustomData.gradients.get("Tint")); + // Internal caches are rebuilt — _updateShaderData would now upload uniforms. The clone is + // produced by the gate rather than replayed through addCurve / addGradient, so 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. + for (const propKey of Object.keys(sourceStream).filter((k) => k.startsWith("prop"))) { + 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; From aa8f4e8a3e43bee7c6ec41b6f79766b6faf30d42 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sat, 18 Jul 2026 23:19:13 +0800 Subject: [PATCH 087/109] docs(clone): state constraints instead of narrating the change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several comments were written to my past self mid-refactor — explaining why a branch sits where it does, what would break if it moved, what the code used to do before. A reader of the merged file has no use for that; the reasoning belongs in history, the constraint belongs in the file. Rewritten to describe the code as it stands: the engine-bound families produce no new object, each cloning branch dedups for itself because sharing must not consult the identity map, a preset aliasing the source must not be filled in place. Same for the class docs (CloneManager imports no engine class; DataObject subclasses must construct bare) and two test comments. Also corrected one comment naming a parameter that no longer exists. Co-Authored-By: Claude Fable 5 --- packages/core/src/base/DataObject.ts | 8 ++---- packages/core/src/clone/CloneManager.ts | 13 ++++----- packages/core/src/clone/CloneUtil.ts | 32 +++++++++------------- tests/src/core/CloneManager.test.ts | 5 +--- tests/src/core/particle/CustomData.test.ts | 3 +- 5 files changed, 24 insertions(+), 37 deletions(-) diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts index 2b7c7637e6..ba54b2f14c 100644 --- a/packages/core/src/base/DataObject.ts +++ b/packages/core/src/base/DataObject.ts @@ -1,9 +1,7 @@ /** * @internal - * Base class marking engine 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 - * (the clone gate recognizes the family via `instanceof`). A subclass should stay constructible - * without arguments: the clone system creates preset-less copies bare and then populates every - * field. Exported only so sibling engine packages (ui) can extend it — not public API for now. + * Base class of engine 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/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 659f56b60b..5bed4cad40 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -26,19 +26,18 @@ export function ignoreClone(target: object, propertyKey: string): void { /** * @internal - * Field-level clone mode registry. Deliberately free of any engine class import: every class that - * uses a clone decorator imports this module while it is still being defined, so pulling an - * engine class in here (directly or through `CloneUtil`) reorders module evaluation and breaks - * `extends` chains. The cloning itself lives in `CloneUtil`. + * 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 _registerFieldMode(target: any, propertyKey: string, mode: CloneMode): void { - // Each class gets its own `_fieldModes`, prototypally chained to its parent's, so native - // lookup resolves inheritance (a subclass re-decorating a field shadows the ancestor's) with - // no registry or cache to keep in sync. + // 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), diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 85e73c7b54..7712c4374b 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -14,10 +14,9 @@ import { CloneMode } from "./enums/CloneMode"; /** * @internal - * Clone execution. Identifies each value by type and clones it in one step — a field decorator - * (highest priority) is honored here, and with no decorator the built-in default by type family - * applies. Engine classes are imported here rather than in `CloneManager`, which every decorated - * class pulls in while still being defined. + * Clone execution: a field decorator takes priority, otherwise the value's own type family + * decides. Engine classes are imported here rather than in `CloneManager`, which cannot import + * them. */ export class CloneUtil { /** @@ -50,15 +49,12 @@ export class CloneUtil { * default. */ static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { - // A function is a value, not a graph: an explicit decorator shares the source function, while - // the default keeps the clone's own constructor-rebound binding when it has one. Checked before - // the non-object guard below, which `typeof fn !== "object"` would otherwise swallow. + // A function is a value, not a graph: an explicit decorator shares the source function, the + // default keeps the clone's own constructor-rebound binding when it has one. if (typeof source === "function") return forceDeepClone ? source : typeof preset === "function" ? preset : source; if (source === null || typeof source !== "object") return source; - // Engine-bound families come first: none of them produces a new object, so they need no dedup - // — an Entity's own map lookup *is* the remap, and the others never enter the map. Being ahead - // of the dedup is also what lets `@deepClone` on one of them be rejected: behind it, an - // in-subtree Entity would resolve to its clone and the misuse would go unnoticed. + // Engine-bound families produce no new object: an Entity's own map lookup is the remap, and + // the others never enter the map. if (source instanceof Entity || source instanceof Component) { if (forceDeepClone) { throw new Error( @@ -92,10 +88,8 @@ export class CloneUtil { } return preset; } - // No dedup here: whether a value produces a clone at all depends on `forceDeepClone` (a - // default-less class is shared by default, cloned when forced), so each branch that actually - // produces one dedups for itself. Looking up first would let a `@deepClone`'d field's copy be - // handed to a plain field that asked to share — and only when it happened to be walked first. + // Each branch that produces a clone dedups for itself: whether a value is cloned at all + // depends on `forceDeepClone`, so a shared value must not consult the identity map. if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap); if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap); @@ -153,8 +147,8 @@ export class CloneUtil { ); (preset)._addReferCount(-1); } - // `cloned === sourceValue` ⇔ the slot shared the source value (only the Assignment path - // returns a registered resource as-is), so it owns one reference. + // `cloned === source` ⇔ the slot shared the source value (only the sharing paths return a + // registered resource as-is), so it owns one reference. if (cloned === source && cloned instanceof ReferResource) { (cloned)._addReferCount(1); } @@ -202,8 +196,8 @@ export class CloneUtil { const existing = cloneMap.get(source); if (existing) return existing; - // `preset !== source`: the clone may still alias the source's array (a class-level default - // table), and filling that in place would write through into the source. + // `preset !== source`: a clone still aliasing the source's array (a class-level default + // table) must not be filled in place. const dst = preset !== source && Array.isArray(preset) && diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 410ba52726..e908a16acd 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -794,10 +794,7 @@ describe("Clone remap", async () => { }); it("does not leak the subclass's re-decoration back onto the base class", () => { - // Decorators run once, at class-definition time — by the time any test runs, - // SubOverrideScript's @ignoreClone on `reDecorated` has already been registered. If that - // registration mutated a store shared with BaseOverrideScript instead of a store of its - // own, every BaseOverrideScript instance (this one included) would inherit the corruption. + // 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"); diff --git a/tests/src/core/particle/CustomData.test.ts b/tests/src/core/particle/CustomData.test.ts index c2d9180abb..53387adaba 100644 --- a/tests/src/core/particle/CustomData.test.ts +++ b/tests/src/core/particle/CustomData.test.ts @@ -332,8 +332,7 @@ 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. The clone is - // produced by the gate rather than replayed through addCurve / addGradient, so every stream + // 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]; From 5ffb29d66af90d1a4789c71ea4bc9d6c8e5d6276 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 11:25:23 +0800 Subject: [PATCH 088/109] fix(core): drop @internal from DataObject so its declaration survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core builds with stripInternal, so the @internal tag erased the class from the emitted types: base/index.d.ts re-exported DataObject from a DataObject.d.ts whose entire content was `export {};`. Nothing in this repo caught it — ui compiles against the built dist and its own type check passed — but a downstream consumer would import a name with no declaration behind it. The tag was wrong regardless: ui's Transition extends DataObject, and a sibling package can only reach it through the public export, so the class has to be visible. Keeping it out of the user-facing docs is what signals it isn't meant for application code. Co-Authored-By: Claude Fable 5 --- packages/core/src/base/DataObject.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts index ba54b2f14c..01d2343692 100644 --- a/packages/core/src/base/DataObject.ts +++ b/packages/core/src/base/DataObject.ts @@ -1,7 +1,6 @@ /** - * @internal - * Base class of engine 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. + * 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 {} From 68616fc5426feb3e9d17ee2c3d1ee85aafd606d7 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 11:48:34 +0800 Subject: [PATCH 089/109] docs(clone): drop comments restating gate behavior at call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream-cache comment in CustomDataModule spelled out how the gate treats maps, identity and plain classes — rules that belong to CloneUtil and are already stated there; repeating them per use site only creates copies to keep in sync. The joint-texture comment shrinks to the part a reader cannot infer: what that lone setTexture(..., null) protects against. Without it the line reads like a pointless nulling and invites removal, which would leak the texture. Co-Authored-By: Claude Fable 5 --- packages/core/src/mesh/SkinnedMeshRenderer.ts | 5 ++--- packages/core/src/particle/modules/CustomDataModule.ts | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts index 78c463f641..1c28bbf015 100644 --- a/packages/core/src/mesh/SkinnedMeshRenderer.ts +++ b/packages/core/src/mesh/SkinnedMeshRenderer.ts @@ -144,9 +144,8 @@ export class SkinnedMeshRenderer extends MeshRenderer { override _cloneTo(target: SkinnedMeshRenderer): void { super._cloneTo(target); - // The joint texture is renderer-private (isGCIgnored): drop the entry ShaderData's clone - // copied, or the source's destroy() is refused (refCount > 0) and the GPU texture leaks. - // The clone's first update rebuilds its own texture (_jointDataCreateCache is fresh). + // The joint texture is renderer-private; the clone builds its own on first update. Leaving + // the copied ShaderData entry would hold a reference the clone never releases. if (this._jointTexture) { target.shaderData.setTexture(SkinnedMeshRenderer._jointSamplerProperty, null); } diff --git a/packages/core/src/particle/modules/CustomDataModule.ts b/packages/core/src/particle/modules/CustomDataModule.ts index 94d753f3fc..3b9ecb91ea 100644 --- a/packages/core/src/particle/modules/CustomDataModule.ts +++ b/packages/core/src/particle/modules/CustomDataModule.ts @@ -55,9 +55,6 @@ export class CustomDataModule extends ParticleGeneratorModule { private _curves: Map = new Map(); private _gradients: Map = new Map(); - // Cloned by the gate alongside `_curves` / `_gradients`: the identity map makes each stream's - // `curve` / `gradient` land on the same clone the map holds, and a `ShaderProperty` — a plain - // class registered globally by name — is shared rather than copied. private _curveStreams: CurveStream[] = []; private _gradientStreams: GradientStream[] = []; From 1d554f6508771363c5b808607810e2d410259a96 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 12:09:31 +0800 Subject: [PATCH 090/109] docs(clone): drop the gate explanation from SubEmitter's back-link The field carried a summary of how the gate treats a Component reference, plus the walk order it relies on. The first is CloneUtil's rule, stated there; the second is an implementation detail no reader of this field needs, and one that would rot silently if the order ever changed. Co-Authored-By: Claude Fable 5 --- packages/core/src/particle/modules/SubEmitter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts index e7939e98ac..a05a0a98c5 100644 --- a/packages/core/src/particle/modules/SubEmitter.ts +++ b/packages/core/src/particle/modules/SubEmitter.ts @@ -19,7 +19,7 @@ export class SubEmitter extends DataObject { /** Number of sub particles emitted per parent event. */ emitCount: number = 1; - /** @internal Back-link; the clone gate remaps it through the identity map (the owning module is registered before its sub-emitters deep-clone). */ + /** @internal */ _module: SubEmittersModule = null; private _emitter: ParticleRenderer = null; From 0494dca569af4d3c67e618efc555a19b8d4de741 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 12:17:35 +0800 Subject: [PATCH 091/109] docs(clone): drop the _createCloneEntity block comment Mostly a retelling of the method name and body, wrapped around a sequencing note that reads as a caveat but describes what clone() plainly does: build the tree, then copy values. Its sibling _parseCloneEntity never carried one; the two now match, and the public clone() keeps its JSDoc. Co-Authored-By: Claude Fable 5 --- packages/core/src/Entity.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index e9270224df..6f3c083ea6 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -442,13 +442,6 @@ export class Entity extends EngineObject { this._templateResource = templateResource; } - /** - * Build the clone's entity/component tree and register every source entity/component to its - * clone in the identity map, so the value-copy pass (`_parseCloneEntity`) can remap references — - * including ones nested in arrays / maps / objects — through the clone gate. Registration - * completes for the whole subtree before any value is copied, because a component anywhere in - * the tree may reference an entity anywhere else in it. - */ private _createCloneEntity(cloneMap: Map): Entity { const componentConstructors = Entity._tempComponentConstructors; const components = this._components; From 030825feea4a08ddc179959d861073e6a112c401 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 12:37:08 +0800 Subject: [PATCH 092/109] docs(clone): drop remaining comments that restate the code Six files' worth of prose that either repeated the method name (_attachToCollider "attaches the native shape") or narrated an implementation the reader can see (_cloneTo assigning through a setter, the bare-construction early return). Also removes an unused Entity import in MeshShape. Co-Authored-By: Claude Fable 5 --- packages/core/src/mesh/SkinnedMeshRenderer.ts | 4 ---- .../core/src/particle/modules/ParticleCompositeGradient.ts | 2 -- packages/core/src/particle/modules/shape/BaseShape.ts | 6 ++---- packages/core/src/particle/modules/shape/MeshShape.ts | 2 -- packages/core/src/physics/shape/ColliderShape.ts | 4 +--- packages/core/src/physics/shape/MeshColliderShape.ts | 2 -- 6 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts index 1c28bbf015..7059cb20f3 100644 --- a/packages/core/src/mesh/SkinnedMeshRenderer.ts +++ b/packages/core/src/mesh/SkinnedMeshRenderer.ts @@ -143,13 +143,9 @@ export class SkinnedMeshRenderer extends MeshRenderer { */ override _cloneTo(target: SkinnedMeshRenderer): void { super._cloneTo(target); - - // The joint texture is renderer-private; the clone builds its own on first update. Leaving - // the copied ShaderData entry would hold a reference the clone never releases. if (this._jointTexture) { target.shaderData.setTexture(SkinnedMeshRenderer._jointSamplerProperty, null); } - if (this.skin) { target._applySkin(null, target.skin); } diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts index d76c59f2ba..d2deb3789a 100644 --- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts +++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts @@ -75,8 +75,6 @@ export class ParticleCompositeGradient extends DataObject { constructor(constantOrGradient?: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) { super(); - // No argument: the field defaults already form a valid constant-mode state (clone gate - // constructs container elements bare, then populates every field). if (!constantOrGradient) return; if (constantOrGradient.constructor === Color) { if (constantMaxOrGradientMax) { diff --git a/packages/core/src/particle/modules/shape/BaseShape.ts b/packages/core/src/particle/modules/shape/BaseShape.ts index c089b47b30..c9184b66ee 100644 --- a/packages/core/src/particle/modules/shape/BaseShape.ts +++ b/packages/core/src/particle/modules/shape/BaseShape.ts @@ -1,8 +1,8 @@ -import { DataObject } from "../../../base/DataObject"; import { BoundingBox, MathUtil, Matrix, Quaternion, Rand, Vector2, Vector3 } from "@galacean/engine-math"; -import { ParticleShapeType } from "./enums/ParticleShapeType"; import { UpdateFlagManager } from "../../../UpdateFlagManager"; +import { DataObject } from "../../../base/DataObject"; import { ignoreClone } from "../../../clone/CloneManager"; +import { ParticleShapeType } from "./enums/ParticleShapeType"; /** * Base class for all particle shapes. @@ -127,8 +127,6 @@ export abstract class BaseShape extends DataObject { /** * @internal - * Called when the hosting generator is destroyed; shapes holding ref-counted resources - * release them here (slot-ownership contract). */ _destroy(): void {} diff --git a/packages/core/src/particle/modules/shape/MeshShape.ts b/packages/core/src/particle/modules/shape/MeshShape.ts index f0cd4f08f3..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"; @@ -53,7 +52,6 @@ export class MeshShape extends BaseShape { /** * @internal - * Release the mesh reference through the setter (pairs its +1; also detaches the listener). */ override _destroy(): void { this.mesh = null; diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 63aecd7a22..1a630991d0 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -19,7 +19,7 @@ export abstract class ColliderShape extends DataObject implements ICustomClone { /** @internal */ @ignoreClone _nativeShape: IColliderShape; - /** @internal Whether the native shape is currently attached to a collider's native actor. */ + /** @internal */ @ignoreClone _isShapeAttached: boolean = false; @@ -184,7 +184,6 @@ export abstract class ColliderShape extends DataObject implements ICustomClone { /** * @internal - * Attach the native shape to the owning collider's native actor. */ _attachToCollider(): void { this._collider._nativeCollider.addShape(this._nativeShape); @@ -193,7 +192,6 @@ export abstract class ColliderShape extends DataObject implements ICustomClone { /** * @internal - * Detach the native shape from the owning collider's native actor. */ _detachFromCollider(): void { this._collider._nativeCollider.removeShape(this._nativeShape); diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts index 6eff3b7df2..e540cc8738 100644 --- a/packages/core/src/physics/shape/MeshColliderShape.ts +++ b/packages/core/src/physics/shape/MeshColliderShape.ts @@ -105,8 +105,6 @@ export class MeshColliderShape extends ColliderShape { * @internal */ override _cloneTo(target: MeshColliderShape): void { - // Runtime state (@ignoreClone) is rebuilt through the setter: refCount +1, mesh-data - // extraction and native shape creation (using the already-copied isConvex/cookingFlags). target.mesh = this._mesh; super._cloneTo(target); } From 702d1a7992684e26974aee7f6943aefaed2a3bc4 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 13:23:55 +0800 Subject: [PATCH 093/109] perf(clone): walk own keys instead of for...in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release bundle downlevels classes with swc `loose`, which emits prototype methods as plain assignments — enumerable, so `for...in` walked them as fields and copied all of them onto the clone as own properties. A cloned MeshRenderer carried 61 own keys against a fresh instance's 31, a cloned ShaderData 41 against 6. `Object.keys` restores what the original implementation used before #1682 swapped it out. The two differ only by inherited enumerable properties, and the engine has none that carry data: instrumenting the walk over the full suite surfaced 2267 such keys across 109 classes, every one a function, and a sweep of all 496 exported classes' prototype chains found no enumerable non-function value. Clones now match a fresh instance's shape, and a 20-entity tree clones in 0.094ms against 0.389ms before. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 7 ++++++- packages/core/src/clone/ComponentCloner.ts | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 7712c4374b..8b229a4969 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -25,7 +25,12 @@ export class CloneUtil { static _deepCloneObject(source: any, target: object, cloneMap: Map): void { // Resolved once per object (a single prototype-chain walk), not once per field. const fieldModes = source._fieldModes; - for (const k in source) { + // Own keys only. The release bundle downlevels classes in `loose` mode, which emits prototype + // methods as plain assignments and thus enumerable; `for...in` would copy them onto the target + // as own properties. + 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); diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index a2a1d9671a..e07e1c5ae6 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -28,7 +28,12 @@ export class ComponentCloner { static cloneComponent(source: Component, target: Component, cloneMap: Map): void { // Resolved once per component (a single prototype-chain walk), not once per field. const fieldModes = (source)._fieldModes; - for (const k in source) { + // Own keys only. The release bundle downlevels classes in `loose` mode, which emits prototype + // methods as plain assignments and thus enumerable; `for...in` would copy them onto the target + // as own properties. + 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]; From 636d19d8a2fd7ff090bef93228a684be7c9ff90a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 13:29:46 +0800 Subject: [PATCH 094/109] docs(clone): cut comments down to the non-derivable constraints Also drops a stale reference to `_cloneClassInstance`, a method renamed out of existence earlier in this branch. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 49 ++++++---------------- packages/core/src/clone/ComponentCloner.ts | 7 +--- 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 8b229a4969..efbed66232 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -14,20 +14,15 @@ import { CloneMode } from "./enums/CloneMode"; /** * @internal - * Clone execution: a field decorator takes priority, otherwise the value's own type family - * decides. Engine classes are imported here rather than in `CloneManager`, which cannot import - * them. + * Split from `CloneManager`, which must stay free of engine imports. */ export class CloneUtil { /** * @internal */ static _deepCloneObject(source: any, target: object, cloneMap: Map): void { - // Resolved once per object (a single prototype-chain walk), not once per field. const fieldModes = source._fieldModes; - // Own keys only. The release bundle downlevels classes in `loose` mode, which emits prototype - // methods as plain assignments and thus enumerable; `for...in` would copy them onto the target - // as own properties. + // Own keys only: `loose` downleveling makes prototype methods enumerable. const keys = Object.keys(source); for (let i = 0, n = keys.length; i < n; i++) { const k = keys[i]; @@ -42,24 +37,18 @@ export class CloneUtil { */ static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { if (fieldMode === CloneMode.Assignment) return source; - // `@deepClone` is an explicit intent: force a deep copy, and throw if it can't be honored. - // `Ignore` never arrives here — the field walk skips those fields before calling in. return CloneUtil._cloneByDefault(source, preset, cloneMap, fieldMode === CloneMode.Deep); } /** * @internal - * `forceDeepClone` (a `@deepClone`'d field) flips a class with no deep-clone default from - * "share" to "field-walk", and makes an engine-bound value throw instead of resolving to its - * default. + * `forceDeepClone` (a `@deepClone`'d field) field-walks a class that has no deep default, and + * throws on an engine-bound value instead of falling back to it. */ static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { - // A function is a value, not a graph: an explicit decorator shares the source function, the - // default keeps the clone's own constructor-rebound binding when it has one. + // The clone's own constructor-rebound binding wins over the source's. if (typeof source === "function") return forceDeepClone ? source : typeof preset === "function" ? preset : source; if (source === null || typeof source !== "object") return source; - // Engine-bound families produce no new object: an Entity's own map lookup is the remap, and - // the others never enter the map. if (source instanceof Entity || source instanceof Component) { if (forceDeepClone) { throw new Error( @@ -67,7 +56,7 @@ export class CloneUtil { `references are engine-bound. Remove @deepClone to remap the reference by default.` ); } - // In-subtree: the clone registered at tree-build time. Outside: keep the original reference. + // Outside the cloned subtree: keep the original reference. return cloneMap.get(source) ?? source; } if (source instanceof ReferResource) { @@ -93,23 +82,17 @@ export class CloneUtil { } return preset; } - // Each branch that produces a clone dedups for itself: whether a value is cloned at all - // depends on `forceDeepClone`, so a shared value must not consult the identity map. + // Dedup lives in each cloning branch: a shared value must not consult the identity map. if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap); if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap); if (source instanceof Set) return CloneUtil._deepCloneSet(source, preset, cloneMap); - // A non-container object. A compatible preset of the exact same type is reused as the clone - // target; otherwise a bare instance is constructed (null-prototype objects have no - // constructor, so rebuild as such). const ctor = (source).constructor; const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; - // Math value type (Vector3, Color, ...) — a class instance carrying a callable copyFrom; copy - // via it. Math cannot depend on core, so it cannot extend DataObject and is recognized this - // way. Plain / null-prototype objects never take this branch, even when a `copyFrom` data - // field rides in the payload. + // Math value types can't extend DataObject (math must not depend on core), so they are + // duck-typed. `ctor !== Object` keeps a plain payload carrying a `copyFrom` field out. if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { const existing = cloneMap.get(source); if (existing) return existing; @@ -120,8 +103,6 @@ export class CloneUtil { return dst; } - // Field-walk deep clone: the DataObject family and plain / null-prototype objects always; any - // other class instance only when `@deepClone` forces it (the default shares it). if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { const existing = cloneMap.get(source); if (existing) return existing; @@ -132,7 +113,6 @@ export class CloneUtil { return dst; } - // A class instance with no deep-clone default, on the default path — shared. return source; } @@ -140,7 +120,6 @@ export class CloneUtil { * @internal */ static _transferSlotOwnership(cloned: any, source: any, preset: any): void { - // Slot content unchanged (Ignore kept / value type copied in place / function reused). if (cloned === preset) return; if (preset instanceof ReferResource) { const presetRefCount = (<{ refCount?: number }>preset).refCount; @@ -152,8 +131,7 @@ export class CloneUtil { ); (preset)._addReferCount(-1); } - // `cloned === source` ⇔ the slot shared the source value (only the sharing paths return a - // registered resource as-is), so it owns one reference. + // `cloned === source` ⇔ the slot shared the source value, so it owns one reference. if (cloned === source && cloned instanceof ReferResource) { (cloned)._addReferCount(1); } @@ -201,8 +179,7 @@ export class CloneUtil { const existing = cloneMap.get(source); if (existing) return existing; - // `preset !== source`: a clone still aliasing the source's array (a class-level default - // table) must not be filled in place. + // `preset !== source`: a clone still aliasing the source's array must not be filled in place. const dst = preset !== source && Array.isArray(preset) && @@ -224,8 +201,7 @@ export class CloneUtil { let dst: Map; if (preset instanceof Map && preset !== source && preset.constructor === source.constructor) { - // Clear before refilling: the preset holds the clone's own constructor-built entries, which - // would otherwise survive alongside the source's. + // The preset's own constructor-built entries would otherwise survive alongside the source's. preset.clear(); dst = preset; } else { @@ -250,7 +226,6 @@ export class CloneUtil { let dst: Set; if (preset instanceof Set && preset !== source && preset.constructor === source.constructor) { - // Cleared before refilling, same as `Map`. preset.clear(); dst = preset; } else { diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index e07e1c5ae6..0cd1e16672 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -13,7 +13,7 @@ export interface ICustomClone { _cloneTo?(target: ICustomClone, cloneMap?: Map): void; /** * @internal - * Value-type marker — `_cloneClassInstance` copies via this instead of walking fields. + * Value-type marker — the gate copies via this instead of walking fields. */ copyFrom?(source: ICustomClone): void; } @@ -26,11 +26,8 @@ export class ComponentCloner { * @param cloneMap - Identity map of the cloned subtree (source object → clone) */ static cloneComponent(source: Component, target: Component, cloneMap: Map): void { - // Resolved once per component (a single prototype-chain walk), not once per field. const fieldModes = (source)._fieldModes; - // Own keys only. The release bundle downlevels classes in `loose` mode, which emits prototype - // methods as plain assignments and thus enumerable; `for...in` would copy them onto the target - // as own properties. + // Own keys only: `loose` downleveling makes prototype methods enumerable. const keys = Object.keys(source); for (let i = 0, n = keys.length; i < n; i++) { const k = keys[i]; From a97b5b46f537d0909bdbc591d49cf4ebd4424335 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 13:34:08 +0800 Subject: [PATCH 095/109] docs(clone): drop the remaining inline comments Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 9 --------- packages/core/src/clone/ComponentCloner.ts | 1 - 2 files changed, 10 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index efbed66232..203ddc7200 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -22,7 +22,6 @@ export class CloneUtil { */ static _deepCloneObject(source: any, target: object, cloneMap: Map): void { const fieldModes = source._fieldModes; - // Own keys only: `loose` downleveling makes prototype methods enumerable. const keys = Object.keys(source); for (let i = 0, n = keys.length; i < n; i++) { const k = keys[i]; @@ -46,7 +45,6 @@ export class CloneUtil { * throws on an engine-bound value instead of falling back to it. */ static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { - // The clone's own constructor-rebound binding wins over the source's. 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) { @@ -56,7 +54,6 @@ export class CloneUtil { `references are engine-bound. Remove @deepClone to remap the reference by default.` ); } - // Outside the cloned subtree: keep the original reference. return cloneMap.get(source) ?? source; } if (source instanceof ReferResource) { @@ -82,7 +79,6 @@ export class CloneUtil { } return preset; } - // Dedup lives in each cloning branch: a shared value must not consult the identity map. if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap); if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap); @@ -91,8 +87,6 @@ export class CloneUtil { const ctor = (source).constructor; const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; - // Math value types can't extend DataObject (math must not depend on core), so they are - // duck-typed. `ctor !== Object` keeps a plain payload carrying a `copyFrom` field out. if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { const existing = cloneMap.get(source); if (existing) return existing; @@ -131,7 +125,6 @@ export class CloneUtil { ); (preset)._addReferCount(-1); } - // `cloned === source` ⇔ the slot shared the source value, so it owns one reference. if (cloned === source && cloned instanceof ReferResource) { (cloned)._addReferCount(1); } @@ -179,7 +172,6 @@ export class CloneUtil { const existing = cloneMap.get(source); if (existing) return existing; - // `preset !== source`: a clone still aliasing the source's array must not be filled in place. const dst = preset !== source && Array.isArray(preset) && @@ -201,7 +193,6 @@ export class CloneUtil { let dst: Map; if (preset instanceof Map && preset !== source && preset.constructor === source.constructor) { - // The preset's own constructor-built entries would otherwise survive alongside the source's. preset.clear(); dst = preset; } else { diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts index 0cd1e16672..b871e4c799 100644 --- a/packages/core/src/clone/ComponentCloner.ts +++ b/packages/core/src/clone/ComponentCloner.ts @@ -27,7 +27,6 @@ export class ComponentCloner { */ static cloneComponent(source: Component, target: Component, cloneMap: Map): void { const fieldModes = (source)._fieldModes; - // Own keys only: `loose` downleveling makes prototype methods enumerable. const keys = Object.keys(source); for (let i = 0, n = keys.length; i < n; i++) { const k = keys[i]; From a1eb05c868da5ffd8e7d8e49291b78feb4f93e04 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 15:34:26 +0800 Subject: [PATCH 096/109] refactor(shader): make _addTexturesReferCount an instance method It touched no static state; both callers are instance methods. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/shader/ShaderData.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/src/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts index 5238904489..c595c57737 100644 --- a/packages/core/src/shader/ShaderData.ts +++ b/packages/core/src/shader/ShaderData.ts @@ -629,7 +629,7 @@ export class ShaderData extends DataObject implements IReferable, IClone { } else if (property instanceof Array || property instanceof Float32Array || property instanceof Int32Array) { const cloned = property.slice(); targetPropertyValueMap[k] = cloned; - referCount > 0 && ShaderData._addTexturesReferCount(cloned, referCount); + referCount > 0 && this._addTexturesReferCount(cloned, referCount); } else { const targetProperty = targetPropertyValueMap[k]; if (targetProperty) { @@ -646,7 +646,6 @@ export class ShaderData extends DataObject implements IReferable, IClone { /** * @internal - * Deep-clone hook invoked by the clone gate; delegates to `cloneTo`. */ _cloneTo(target: ShaderData): void { this.cloneTo(target); @@ -722,14 +721,11 @@ export class ShaderData extends DataObject implements IReferable, IClone { for (const k in properties) { const property = properties[k]; // @todo: Separate array to speed performance. - property && ShaderData._addTexturesReferCount(property, value); + property && this._addTexturesReferCount(property, value); } } - /** - * Counted texture content may be a single texture or a texture array; both cascade alike. - */ - private static _addTexturesReferCount(property: ShaderPropertyValueType, count: number): void { + private _addTexturesReferCount(property: ShaderPropertyValueType, count: number): void { if (property instanceof Texture) { property._addReferCount(count); } else if (property instanceof Array) { From 9be79cfc76fec405beefb0ab9957e1e3192f9e5c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 15:39:40 +0800 Subject: [PATCH 097/109] docs(clone): drop narrative comments from Signal and Transition Co-Authored-By: Claude Opus 4.8 --- packages/core/src/Signal.ts | 2 -- .../ui/src/component/interactive/transition/Transition.ts | 5 ----- 2 files changed, 7 deletions(-) diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index dfe89cf862..f0418f0cc5 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -124,8 +124,6 @@ export class Signal extends DataObject { /** * @internal - * Clone listeners to target signal, remapping entity/component references through the - * cloned subtree's identity map (references outside the subtree are kept as-is). */ _cloneTo(target: Signal, cloneMap: Map): void { const listeners = this._listeners.getLoopArray(); diff --git a/packages/ui/src/component/interactive/transition/Transition.ts b/packages/ui/src/component/interactive/transition/Transition.ts index 01794f60a7..1b76932098 100644 --- a/packages/ui/src/component/interactive/transition/Transition.ts +++ b/packages/ui/src/component/interactive/transition/Transition.ts @@ -18,8 +18,6 @@ export abstract class Transition< protected _hover: T; protected _disabled: T; protected _duration: number = 0; - // Transient run state — rebuilt by `_setState` when the cloned interactive activates; the - // slots own no resource reference (destroy only releases the four state values). @ignoreClone protected _countDown: number = 0; @ignoreClone @@ -126,7 +124,6 @@ export abstract class Transition< destroy(): void { this._interactive?.removeTransition(this); - // Release the state values (paired with the setter's +1 and, for clones, `_cloneTo`'s +1). this._addStateValuesReferCount(-1); this._normal = this._pressed = this._hover = this._disabled = null; this._initialValue = this._currentValue = this._finalValue = null; @@ -135,8 +132,6 @@ export abstract class Transition< /** * @internal - * The clone gate shares the state values without counting (nested level); the clone owns - * one reference per ref-counted state — released in `destroy`. */ _cloneTo(target: Transition): void { target._addStateValuesReferCount(1); From 36472b39a544756f48d1cc4e8fd1cb26443aa1af Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 16:51:51 +0800 Subject: [PATCH 098/109] fix(clone): let @deepClone override the plain-container default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate lumped four types into one "engine runtime state" branch that threw on @deepClone. Two of them are not alike: DisorderedArray and SafeLoopArray are plain containers a field walk copies correctly, so keeping the clone's own instance is a default, and a default a field decorator must be able to override. UpdateFlagManager and UpdateFlag stay unoverridable — a flag and its manager hold each other, and a field copy resolves neither side, so the throw now says that instead of citing transience. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 14 ++++------ tests/src/core/CloneManager.test.ts | 42 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 203ddc7200..2f1e5d3ad9 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -65,20 +65,18 @@ export class CloneUtil { } return source; } - if ( - source instanceof UpdateFlagManager || - source instanceof UpdateFlag || - source instanceof DisorderedArray || - source instanceof SafeLoopArray - ) { + if (source instanceof UpdateFlagManager || source instanceof UpdateFlag) { if (forceDeepClone) { throw new Error( - `CloneUtil: @deepClone cannot deep clone "${source.constructor.name}" — engine runtime state is ` + - `transient and belongs to the instance holding it. Remove @deepClone to keep the clone's own.` + `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.` ); } return preset; } + // Containers whose contents `@deepClone` can copy; the default still keeps the clone's own. + if (!forceDeepClone && (source instanceof DisorderedArray || source instanceof SafeLoopArray)) return preset; if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap); if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap); diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index e908a16acd..8596ee535f 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1,6 +1,7 @@ import { Burst, DataObject, + DisorderedArray, Entity, Logger, MeshRenderer, @@ -139,6 +140,18 @@ class HandlerScript extends Script { config: { onDone: (() => void) | null; x: number } = { onDone: null, x: 0 }; } +/** 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; @@ -1222,6 +1235,35 @@ describe("Clone remap", async () => { 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", () => { From b0f8226fb950b25a3f5111cc7183681008622bf4 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 17:12:29 +0800 Subject: [PATCH 099/109] refactor(clone): fold the two cloning branches into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ran the same sequence — consult the identity map, build the target, register it, populate, run the `_cloneTo` hook — differing only in how they populate. Keeping them apart made "every branch that produces a clone registers it before descending" a convention repeated twice; folded, it holds structurally. `reusable` now resolves only on the producing path, not on the far more common shared one. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 2f1e5d3ad9..684c7732b8 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -83,24 +83,19 @@ export class CloneUtil { if (source instanceof Set) return CloneUtil._deepCloneSet(source, preset, cloneMap); const ctor = (source).constructor; - const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; + // Math value types can't extend DataObject (math must not depend on core), so they are + // duck-typed. `ctor !== Object` keeps a plain payload carrying a `copyFrom` field out. + const useCopyFrom = ctor && ctor !== Object && typeof (source).copyFrom === "function"; - if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { - const existing = cloneMap.get(source); - if (existing) return existing; - const dst = (reusable ?? CloneUtil._bareConstruct(ctor)); - cloneMap.set(source, dst); - dst.copyFrom(source); - (source)._cloneTo?.(dst, cloneMap); - return dst; - } - - if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { + if (useCopyFrom || source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { const existing = cloneMap.get(source); if (existing) return existing; + const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); cloneMap.set(source, dst); - CloneUtil._deepCloneObject(source, dst, cloneMap); + useCopyFrom + ? (dst).copyFrom(source) + : CloneUtil._deepCloneObject(source, dst, cloneMap); (source)._cloneTo?.(dst, cloneMap); return dst; } From 3d06a6d26bf869ad9136ccebbe627d8b2ae6ab4c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 17:44:42 +0800 Subject: [PATCH 100/109] refactor(clone): extract the construct-and-register step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_deepConstructObject` resolves the target instance — reuse the clone's preset or bare-construct one — and registers it before the caller descends. The identity lookup stays at the call sites: a slot resolving to an already-cloned object must return it untouched, and a helper that answered the lookup itself would leave the caller no way to tell a fresh instance from an existing one, replaying the populate step and the `_cloneTo` hook. Hooks acquire references and register listeners, so a replay silently doubles both; the added test pins it. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 76 ++++++++++++++++++---------- tests/src/core/CloneManager.test.ts | 38 ++++++++++++++ 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 684c7732b8..1702f86c5e 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -75,34 +75,69 @@ export class CloneUtil { } return preset; } - // Containers whose contents `@deepClone` can copy; the default still keeps the clone's own. - if (!forceDeepClone && (source instanceof DisorderedArray || source instanceof SafeLoopArray)) return preset; if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap); if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap); if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap); if (source instanceof Set) return CloneUtil._deepCloneSet(source, preset, cloneMap); + if (source instanceof DisorderedArray || source instanceof SafeLoopArray) { + if (!forceDeepClone) return preset; + const existing = cloneMap.get(source); + if (existing) return existing; + const dst = CloneUtil._deepConstructObject(source, preset, cloneMap); + CloneUtil._deepCloneObject(source, dst, cloneMap); + return dst; + } const ctor = (source).constructor; - // Math value types can't extend DataObject (math must not depend on core), so they are - // duck-typed. `ctor !== Object` keeps a plain payload carrying a `copyFrom` field out. - const useCopyFrom = ctor && ctor !== Object && typeof (source).copyFrom === "function"; - - if (useCopyFrom || source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { + if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { const existing = cloneMap.get(source); if (existing) return existing; - const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null; - const dst = reusable ?? (ctor ? CloneUtil._bareConstruct(ctor) : Object.create(null)); - cloneMap.set(source, dst); - useCopyFrom - ? (dst).copyFrom(source) - : CloneUtil._deepCloneObject(source, dst, cloneMap); + const dst = CloneUtil._deepConstructObject(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._deepConstructObject(source, preset, cloneMap); + CloneUtil._deepCloneObject(source, dst, cloneMap); + (source)._cloneTo?.(dst, cloneMap); + return dst; + } return source; } + /** + * @internal + * Registers the new instance before the caller descends, so a cycle resolves to it. + */ + static _deepConstructObject(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 */ @@ -219,19 +254,4 @@ export class CloneUtil { for (const v of source) dst.add(CloneUtil._cloneValue(v, undefined, cloneMap)); return dst; } - - /** - * @internal - */ - static _bareConstruct(ctor: new () => any): any { - try { - return 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}` - ); - } - } } diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 8596ee535f..c87269bcb6 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -140,6 +140,21 @@ class HandlerScript extends Script { 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 @@ -497,6 +512,29 @@ describe("Clone remap", async () => { }); 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"); From 729573550473952ffd40ce3cd04e60f4e667091d Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 19:07:29 +0800 Subject: [PATCH 101/109] refactor(clone): rename _deepConstructObject to _createCloneTarget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing about it is deep — it hands back an empty instance and leaves the recursion to the caller, which is exactly the misreading to avoid. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 1702f86c5e..529dcdbb13 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -83,7 +83,7 @@ export class CloneUtil { if (!forceDeepClone) return preset; const existing = cloneMap.get(source); if (existing) return existing; - const dst = CloneUtil._deepConstructObject(source, preset, cloneMap); + const dst = CloneUtil._createCloneTarget(source, preset, cloneMap); CloneUtil._deepCloneObject(source, dst, cloneMap); return dst; } @@ -92,7 +92,7 @@ export class CloneUtil { if (ctor && ctor !== Object && typeof (source).copyFrom === "function") { const existing = cloneMap.get(source); if (existing) return existing; - const dst = CloneUtil._deepConstructObject(source, preset, cloneMap); + const dst = CloneUtil._createCloneTarget(source, preset, cloneMap); (dst).copyFrom(source); (source)._cloneTo?.(dst, cloneMap); return dst; @@ -101,7 +101,7 @@ export class CloneUtil { if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) { const existing = cloneMap.get(source); if (existing) return existing; - const dst = CloneUtil._deepConstructObject(source, preset, cloneMap); + const dst = CloneUtil._createCloneTarget(source, preset, cloneMap); CloneUtil._deepCloneObject(source, dst, cloneMap); (source)._cloneTo?.(dst, cloneMap); return dst; @@ -113,7 +113,7 @@ export class CloneUtil { * @internal * Registers the new instance before the caller descends, so a cycle resolves to it. */ - static _deepConstructObject(source: any, preset: any, cloneMap: Map): any { + 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; From dbb7624c8d1d1b4fbe6b887089c83a5024d6158f Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 23:09:26 +0800 Subject: [PATCH 102/109] docs(clone): document the deep-default opt-ins, fix the release guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataObject / copyFrom (plus the bare-construction requirement) were the two ways a type earns the deep default, and neither was written down. The ref-counting section told script authors to release in onDestroy, which the public surface cannot do — point at the asset's own destroy() instead. Co-Authored-By: Claude Opus 4.8 --- docs/en/core/clone.mdx | 4 +++- docs/zh/core/clone.mdx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 8efb0f329c..7a243d86e0 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -55,6 +55,8 @@ When a field has no clone decorator, the engine resolves how to clone its value | Runtime containers (internal transient state such as `UpdateFlagManager`) | Ignored — the clone keeps its own constructor-built value. | | 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 @@ -146,4 +148,4 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - 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. The class holding the field is responsible for releasing that reference when it is destroyed — built-in components already handle this; for custom scripts, release the resources you hold in `onDestroy`. +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/zh/core/clone.mdx b/docs/zh/core/clone.mdx index 5ff13a2c4d..c350256d97 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -56,6 +56,8 @@ console.log(cloneScript.c); // output is (1,1,1),且是一份独立拷贝 — | 运行时容器(`UpdateFlagManager` 等内部瞬态状态) | 忽略 —— 克隆体保留自身构造时的值。 | | 其他对象 | 共享引用(赋值)。 | +一个类可以通过两种方式获得"默认深克隆"能力:继承 [DataObject](/apis/core/#DataObject)(数据类 —— 引擎逐字段克隆),或提供 `copyFrom` 方法(数学类这样的值类型 —— 引擎经由它拷贝)。两种方式都要求类型支持无参构造:当槽位上没有可复用的预置实例时,引擎会先 `new Type()` 裸构造一个再填充,无法无参构造的类型会直接报错。 + 因此在不加装饰器的情况下,普通数据对象和数组默认会得到独立的深拷贝,资产依旧共享,实体引用则自动重映射: ```typescript // define a custom script @@ -147,4 +149,4 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 ## 克隆与资产引用计数 -克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数。持有该字段的类负责在销毁时释放这次引用 —— 内置组件已由引擎处理;自定义脚本请在 `onDestroy` 中释放自己持有的资源。 +克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数,因此销毁源对象不会让资产在克隆体脚下被回收。内置组件会在销毁时释放自己持有的引用;被自定义脚本字段引用的资产则由克隆体一直保活 —— 不再需要时,调用资产自身的 `destroy()` 释放。 From 983fe599e7ac5a39ae65245e904ed49944189416 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 23:19:52 +0800 Subject: [PATCH 103/109] fix(core): stop re-exporting the stripped CloneManager class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class is @internal and stripInternal erases it from the emitted .d.ts, so the index re-export dangled — a TS2305 for any consumer compiling without skipLibCheck. Only the three decorators are public API; nothing outside core imports the class. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e3e1f6b3b6..5e83b9259b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,7 +69,7 @@ export * from "./trail/index"; export * from "./env-probe/index"; export * from "./shader/index"; export * from "./Layer"; -export { CloneManager, deepClone, assignmentClone, ignoreClone } from "./clone/CloneManager"; +export { deepClone, assignmentClone, ignoreClone } from "./clone/CloneManager"; export * from "./renderingHardwareInterface/index"; export * from "./physics/index"; export * from "./Utils"; From a4dc14b282a6b4b5b3713e6f05ffb863a29f7938 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 23:25:21 +0800 Subject: [PATCH 104/109] docs(clone): close the review-found gaps in the rules Adds the function-value row, notes Map keys are cloned too, widens the @deepClone throw list to engine runtime state, narrows "any depth" to where a field walk actually happens, states that @deepClone does not propagate into members without a deep default, and makes example 2 assign the texture its assertion claims to demonstrate. Co-Authored-By: Claude Opus 4.8 --- docs/en/core/clone.mdx | 14 ++++++++------ docs/zh/core/clone.mdx | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 7a243d86e0..5baa291851 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -51,8 +51,9 @@ When a field has no clone decorator, the engine resolves how to clone its 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 is cloned again according to its own type semantics. | +| 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. @@ -76,6 +77,7 @@ 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(); @@ -85,13 +87,13 @@ console.log(cloneScript.config === script.config); // output is false, plain dat 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 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 take effect at any depth of the cloned object graph. The engine has three 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; 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 during cloning. The field gets a fresh, independent structure, and each inner member is cloned again according to its own semantics. Engine-bound values cannot be deep cloned: `@deepClone` on an `Entity` / `Component` reference or an asset throws — remove the decorator to get the default behavior (remapping / sharing), or copy an asset via its own clone API. | +| [deepClone](/apis/core/#deepClone) | Deep clone the field during cloning. The field gets a fresh, independent structure, and each inner member is cloned again according to its own semantics. Engine-bound values cannot be deep cloned: `@deepClone` on an `Entity` / `Component` reference, an asset, or engine runtime state (such as `UpdateFlagManager`) 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. @@ -142,9 +144,9 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t ``` - Note: - - Field decorators have the highest priority and also take effect on the fields of nested classes at any depth of the cloned object graph. - - `deepClone` recursively clones the structure, while each inner member is still cloned according to its own semantics: assets stay shared, entity references are remapped, and nested field decorators still apply. - - `deepClone` cannot deep clone engine-bound objects: `@deepClone` on an `Entity` / `Component` reference or an asset throws, because the explicit intent can't be honored. Remove the decorator to fall back to the default (remapping / sharing), or use the asset's own clone API for a real copy. + - Field decorators have the highest priority and also apply to the fields of nested objects, wherever the clone walks fields. + - `deepClone` recursively clones the structure, while each inner member is still cloned according to its own semantics: assets stay shared, entity references are remapped, and nested field decorators still apply. A member class instance with no deep default (neither a `DataObject` nor a `copyFrom` value type) stays shared — the decorator does not propagate itself into members; opt the member's type into deep cloning instead. + - `deepClone` cannot deep clone engine-bound objects: `@deepClone` on an `Entity` / `Component` reference, an asset, or engine runtime state 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 diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index c350256d97..e96fd7e46a 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -52,8 +52,9 @@ console.log(cloneScript.c); // output is (1,1,1),且是一份独立拷贝 — | 资产(`Texture`、`Mesh`、`Material`、`Sprite`、`Font` 等 `ReferResource` 类型) | 共享引用 —— 克隆体与源使用同一份资产。 | | `Entity` / `Component` 引用 | 自动重映射到克隆子树内对应的克隆对象;指向子树外的引用保持原引用不变。 | | 数学值类型(`Vector2`、`Vector3`、`Vector4`、`Quaternion`、`Matrix`、`Color` 等)及其他值语义数据 | 深克隆 —— 克隆体获得完全独立的拷贝。 | -| 容器(`Array`、`Map`、`Set`、TypedArray、`DataView`、普通对象) | 深克隆 —— 创建全新的容器,每个成员再按各自的类型语义继续克隆。 | +| 容器(`Array`、`Map`、`Set`、TypedArray、`DataView`、普通对象) | 深克隆 —— 创建全新的容器,每个成员(`Map` 连键一起)再按各自的类型语义继续克隆。 | | 运行时容器(`UpdateFlagManager` 等内部瞬态状态) | 忽略 —— 克隆体保留自身构造时的值。 | +| 函数值 | 克隆体保留自身构造时的函数;没有则共享源的函数。 | | 其他对象 | 共享引用(赋值)。 | 一个类可以通过两种方式获得"默认深克隆"能力:继承 [DataObject](/apis/core/#DataObject)(数据类 —— 引擎逐字段克隆),或提供 `copyFrom` 方法(数学类这样的值类型 —— 引擎经由它拷贝)。两种方式都要求类型支持无参构造:当槽位上没有可复用的预置实例时,引擎会先 `new Type()` 裸构造一个再填充,无法无参构造的类型会直接报错。 @@ -77,6 +78,7 @@ 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(); @@ -86,13 +88,13 @@ 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) | 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型,克隆体将与源共享同一个引用。 | -| [deepClone](/apis/core/#deepClone) | 克隆时对字段进行深克隆。字段会获得全新且独立的结构,内部每个成员再按各自的语义继续克隆。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用或资产使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为(重映射 / 共享),或使用资产自身的克隆 API 复制资产。| +| [deepClone](/apis/core/#deepClone) | 克隆时对字段进行深克隆。字段会获得全新且独立的结构,内部每个成员再按各自的语义继续克隆。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产或引擎运行时状态(如 `UpdateFlagManager`)使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。| `@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 `@deepClone`,希望共享就使用 `@assignmentClone`。 @@ -143,9 +145,9 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t ``` - 注意: - - 字段装饰器优先级最高,并且对克隆对象图任意深度上嵌套类的字段同样生效。 - - `deepClone` 会对结构进行深度递归克隆,内部每个成员仍按各自的语义克隆:资产保持共享,实体引用被重映射,嵌套字段上的装饰器依然生效。 - - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用或资产使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为(重映射 / 共享),或使用资产自身的克隆 API 得到真正的拷贝。 + - 字段装饰器优先级最高,并且在克隆走字段遍历的任何地方对嵌套对象的字段同样生效。 + - `deepClone` 会对结构进行深度递归克隆,内部每个成员仍按各自的语义克隆:资产保持共享,实体引用被重映射,嵌套字段上的装饰器依然生效。没有深克隆默认的成员类实例(既不是 `DataObject` 也没有 `copyFrom`)会保持共享 —— 装饰器不会把自己传播进成员,请让成员的类型自己获得深克隆能力。 + - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用、资产或引擎运行时状态使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为,或使用资产自身的克隆 API 得到真正的拷贝。 - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 ## 克隆与资产引用计数 From f37945fb4b477714448d2ec2baa39f25848b79dc Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 23:41:11 +0800 Subject: [PATCH 105/109] test(clone): pin the review-found contract gaps Covers @assignmentClone on a container, Map key cloning (object keys miss by design, entity keys remap), a function as a Map value, self-referencing plain data, binary preset reuse vs replacement, undecorated SafeLoopArray / UpdateFlag slots, a Ray field through the gate's copyFrom dispatch, the particle MeshShape clone round-trip, cloneTo into a referenced populated ShaderData (current displacement semantics), template-marked source counting, and makes the CustomData ShaderProperty sweep assert it matched anything at all. Co-Authored-By: Claude Opus 4.8 --- tests/src/core/CloneManager.test.ts | 190 ++++++++++++++++++++- tests/src/core/RefCountContract.test.ts | 42 +++++ tests/src/core/ShaderDataRefCount.test.ts | 23 +++ tests/src/core/particle/CustomData.test.ts | 4 +- 4 files changed, 257 insertions(+), 2 deletions(-) diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index c87269bcb6..af41c4ccba 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -1,4 +1,5 @@ import { + BoolUpdateFlag, Burst, DataObject, DisorderedArray, @@ -18,7 +19,7 @@ import { import * as EngineCore from "@galacean/engine-core"; import * as EngineMath from "@galacean/engine-math"; import * as EngineUI from "@galacean/engine-ui"; -import { Color, Vector3 } from "@galacean/engine-math"; +import { Color, Ray, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { describe, expect, it, vi } from "vitest"; @@ -313,6 +314,25 @@ class PlainConfig { nested = { x: 1 }; } +/** Script sharing one container through an @assignmentClone field */ +class AssignedContainerScript extends Script { + @assignmentClone + shared: number[] = []; +} + +/** 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; @@ -1274,6 +1294,25 @@ describe("Clone remap", async () => { 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"); @@ -1352,6 +1391,96 @@ describe("Clone remap", async () => { }); }); + 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("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"); @@ -1376,9 +1505,68 @@ describe("Clone remap", async () => { 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"); diff --git a/tests/src/core/RefCountContract.test.ts b/tests/src/core/RefCountContract.test.ts index bb5fc68066..392aeb1520 100644 --- a/tests/src/core/RefCountContract.test.ts +++ b/tests/src/core/RefCountContract.test.ts @@ -220,5 +220,47 @@ describe("RefCount slot-ownership contract", () => { 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 index 84027d2014..1a7560e3d2 100644 --- a/tests/src/core/ShaderDataRefCount.test.ts +++ b/tests/src/core/ShaderDataRefCount.test.ts @@ -157,4 +157,27 @@ describe("ShaderData / Material refCount cascade", () => { 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/particle/CustomData.test.ts b/tests/src/core/particle/CustomData.test.ts index 53387adaba..2cd348297f 100644 --- a/tests/src/core/particle/CustomData.test.ts +++ b/tests/src/core/particle/CustomData.test.ts @@ -349,7 +349,9 @@ describe("CustomDataModule", function () { // 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. - for (const propKey of Object.keys(sourceStream).filter((k) => k.startsWith("prop"))) { + 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]); } From 64410a6ab3433db62b4d305514a896f842d34009 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 23:55:37 +0800 Subject: [PATCH 106/109] feat(clone): @deepClone deep-clones the whole subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decorator used to stop at the field it decorated: a @deepClone'd container got a fresh shell holding shared members, silently diverging from the previous implementation, which recursed the mode into elements. The deep intent now carries into members — a class with no deep default of its own is copied too — while staying soft inside the subtree: assets share, entity refs remap, runtime state keeps the clone's own. The honor-or-throw check applies only to the value the decorator sits on, and moves to _cloneValue accordingly. Signal._listeners regains an explicit @ignoreClone: it is rebuilt by _cloneTo, and the type-family default alone no longer shields it from a propagated force. Co-Authored-By: Claude Opus 4.8 --- docs/en/core/clone.mdx | 4 +- docs/zh/core/clone.mdx | 4 +- packages/core/src/Signal.ts | 3 + packages/core/src/clone/CloneManager.ts | 9 +- packages/core/src/clone/CloneUtil.ts | 113 ++++++++++++--------- packages/core/src/clone/enums/CloneMode.ts | 5 +- tests/src/core/CloneManager.test.ts | 65 ++++++++++++ 7 files changed, 147 insertions(+), 56 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 5baa291851..8cff29f592 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -93,7 +93,7 @@ In addition to the default type-driven rules, the engine also provides "clone de | :--- | :--- | | [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 during cloning. The field gets a fresh, independent structure, and each inner member is cloned again according to its own semantics. Engine-bound values cannot be deep cloned: `@deepClone` on an `Entity` / `Component` reference, an asset, or engine runtime state (such as `UpdateFlagManager`) throws — remove the decorator to get the default behavior, or copy an asset via its own clone API. | +| [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, or engine runtime state (such as `UpdateFlagManager`) 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. @@ -145,7 +145,7 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - Note: - Field decorators have the highest priority and also apply to the fields of nested objects, wherever the clone walks fields. - - `deepClone` recursively clones the structure, while each inner member is still cloned according to its own semantics: assets stay shared, entity references are remapped, and nested field decorators still apply. A member class instance with no deep default (neither a `DataObject` nor a `copyFrom` value type) stays shared — the decorator does not propagate itself into members; opt the member's type into deep cloning instead. + - `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, or engine runtime state 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. diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index e96fd7e46a..e31453c648 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -94,7 +94,7 @@ console.log(cloneScript.texture === script.texture); // output is true,资产 | :--- | :--- | | [ignoreClone](/apis/core/#ignoreClone) | 克隆时对字段进行忽略,克隆体保留自身构造时的值。 | | [assignmentClone](/apis/core/#assignmentClone) | 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型,克隆体将与源共享同一个引用。 | -| [deepClone](/apis/core/#deepClone) | 克隆时对字段进行深克隆。字段会获得全新且独立的结构,内部每个成员再按各自的语义继续克隆。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产或引擎运行时状态(如 `UpdateFlagManager`)使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。| +| [deepClone](/apis/core/#deepClone) | 克隆时深克隆字段的整棵子树。深意图会传播进成员;子树内的资产保持共享、实体引用重映射、运行时状态保留克隆体自己的。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产或引擎运行时状态(如 `UpdateFlagManager`)使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。| `@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 `@deepClone`,希望共享就使用 `@assignmentClone`。 @@ -146,7 +146,7 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - 注意: - 字段装饰器优先级最高,并且在克隆走字段遍历的任何地方对嵌套对象的字段同样生效。 - - `deepClone` 会对结构进行深度递归克隆,内部每个成员仍按各自的语义克隆:资产保持共享,实体引用被重映射,嵌套字段上的装饰器依然生效。没有深克隆默认的成员类实例(既不是 `DataObject` 也没有 `copyFrom`)会保持共享 —— 装饰器不会把自己传播进成员,请让成员的类型自己获得深克隆能力。 + - `deepClone` 会深克隆整棵子树:深意图会传播进成员,没有深克隆默认的成员类实例同样被拷贝(因此必须支持无参构造)。子树内的资产保持共享,实体引用被重映射,运行时状态保留克隆体自己的,嵌套字段上的装饰器依然优先。 - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用、资产或引擎运行时状态使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为,或使用资产自身的克隆 API 得到真正的拷贝。 - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts index f0418f0cc5..6d34f69eb5 100644 --- a/packages/core/src/Signal.ts +++ b/packages/core/src/Signal.ts @@ -1,4 +1,5 @@ import { DataObject } from "./base/DataObject"; +import { ignoreClone } from "./clone/CloneManager"; import { Component } from "./Component"; import { Entity } from "./Entity"; import { SafeLoopArray } from "./utils/SafeLoopArray"; @@ -8,6 +9,8 @@ import { SafeLoopArray } from "./utils/SafeLoopArray"; * @typeParam T - Tuple type of the signal arguments */ export class Signal extends DataObject { + // Rebuilt by `_cloneTo`; must survive even a propagated @deepClone. + @ignoreClone private _listeners: SafeLoopArray> = new SafeLoopArray>(); /** diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index 5bed4cad40..c71afa97c1 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -1,10 +1,11 @@ import { CloneMode } from "./enums/CloneMode"; /** - * Property decorator — deep clone this field, overriding the value type's default clone mode - * (field-level decorators have the highest priority). A decorator is an explicit intent: if the - * value can't be deep cloned (an entity reference or an asset), cloning throws rather than - * silently falling back. + * 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 or an asset), cloning throws rather than silently falling back. */ export function deepClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 529dcdbb13..e7a1fcaea9 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -20,71 +20,59 @@ export class CloneUtil { /** * @internal */ - static _deepCloneObject(source: any, target: object, cloneMap: Map): void { + 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); + target[k] = CloneUtil._cloneValue(source[k], target[k], cloneMap, fieldMode, forceDeepClone); } } /** * @internal */ - static _cloneValue(source: any, preset: any, cloneMap: Map, fieldMode?: CloneMode): any { + static _cloneValue( + source: any, + preset: any, + cloneMap: Map, + fieldMode?: CloneMode, + forceDeepClone = false + ): any { if (fieldMode === CloneMode.Assignment) return source; - return CloneUtil._cloneByDefault(source, preset, cloneMap, fieldMode === CloneMode.Deep); + if (fieldMode === CloneMode.Deep) { + // The explicit decorator must be honorable on the value it decorates; the deep intent it + // propagates into the subtree is softer — engine-bound members keep their defaults. + CloneUtil._assertDeepClonable(source); + forceDeepClone = true; + } + return CloneUtil._cloneByDefault(source, preset, cloneMap, forceDeepClone); } /** * @internal - * `forceDeepClone` (a `@deepClone`'d field) field-walks a class that has no deep default, and - * throws on an engine-bound value instead of falling back to it. + * `forceDeepClone` (a `@deepClone` somewhere up the walk) deep-copies the whole subtree: a + * class with no deep default flips from "share" to "field-walk", and containers carry the + * force into their members. Engine-bound values keep their defaults (remap / share / own). */ 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) { - if (forceDeepClone) { - 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.` - ); - } - return cloneMap.get(source) ?? source; - } - if (source instanceof ReferResource) { - if (forceDeepClone) { - 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.` - ); - } - return source; - } - if (source instanceof UpdateFlagManager || source instanceof UpdateFlag) { - if (forceDeepClone) { - 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.` - ); - } - return preset; - } + 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); - if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap); - if (source instanceof Set) return CloneUtil._deepCloneSet(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); + CloneUtil._deepCloneObject(source, dst, cloneMap, true); return dst; } @@ -102,13 +90,39 @@ export class CloneUtil { const existing = cloneMap.get(source); if (existing) return existing; const dst = CloneUtil._createCloneTarget(source, preset, cloneMap); - CloneUtil._deepCloneObject(source, dst, cloneMap); + CloneUtil._deepCloneObject(source, dst, cloneMap, forceDeepClone); (source)._cloneTo?.(dst, cloneMap); return dst; } return source; } + /** + * @internal + * Throws when a `@deepClone`-decorated value cannot be deep cloned. + */ + static _assertDeepClonable(source: any): void { + 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 * Registers the new instance before the caller descends, so a cycle resolves to it. @@ -196,7 +210,7 @@ export class CloneUtil { /** * @internal */ - static _deepCloneArray(source: any[], preset: any, cloneMap: Map): any[] { + static _deepCloneArray(source: any[], preset: any, cloneMap: Map, forceDeepClone = false): any[] { const existing = cloneMap.get(source); if (existing) return existing; @@ -208,14 +222,21 @@ export class CloneUtil { ? preset : new Array(source.length); cloneMap.set(source, dst); - for (let i = 0, n = source.length; i < n; i++) dst[i] = CloneUtil._cloneValue(source[i], undefined, cloneMap); + 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): Map { + static _deepCloneMap( + source: Map, + preset: any, + cloneMap: Map, + forceDeepClone = false + ): Map { const existing = cloneMap.get(source); if (existing) return >existing; @@ -229,8 +250,8 @@ export class CloneUtil { cloneMap.set(source, dst); for (const entry of source) { dst.set( - CloneUtil._cloneValue(entry[0], undefined, cloneMap), - CloneUtil._cloneValue(entry[1], undefined, cloneMap) + CloneUtil._cloneByDefault(entry[0], undefined, cloneMap, forceDeepClone), + CloneUtil._cloneByDefault(entry[1], undefined, cloneMap, forceDeepClone) ); } return dst; @@ -239,7 +260,7 @@ export class CloneUtil { /** * @internal */ - static _deepCloneSet(source: Set, preset: any, cloneMap: Map): Set { + static _deepCloneSet(source: Set, preset: any, cloneMap: Map, forceDeepClone = false): Set { const existing = cloneMap.get(source); if (existing) return >existing; @@ -251,7 +272,7 @@ export class CloneUtil { dst = new Set(); } cloneMap.set(source, dst); - for (const v of source) dst.add(CloneUtil._cloneValue(v, undefined, cloneMap)); + for (const v of source) dst.add(CloneUtil._cloneByDefault(v, undefined, cloneMap, forceDeepClone)); return dst; } } diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts index f3a3b3938c..b4e41ad31a 100644 --- a/packages/core/src/clone/enums/CloneMode.ts +++ b/packages/core/src/clone/enums/CloneMode.ts @@ -7,8 +7,9 @@ export enum CloneMode { /** Share the reference; a counted resource shared at a component's top-level slot is kept alive by the clone. */ Assignment, /** - * Recursively clone the graph structure (fresh containers/instances); each member follows its - * own clone semantics — assets stay shared, entity refs remap, runtime state is ignored. + * 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/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index af41c4ccba..3dd5431911 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -320,6 +320,16 @@ class AssignedContainerScript extends Script { shared: number[] = []; } +/** 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); @@ -1461,6 +1471,61 @@ describe("Clone remap", async () => { }); }); + 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"); From 8a954232a790b48c6c7313a3936832d7034f1542 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 19 Jul 2026 23:57:21 +0800 Subject: [PATCH 107/109] style(docs): run prettier over the clone pages Co-Authored-By: Claude Opus 4.8 --- docs/en/core/clone.mdx | 62 ++++++++++++++++++++++++--------------- docs/zh/core/clone.mdx | 66 +++++++++++++++++++++++++----------------- 2 files changed, 77 insertions(+), 51 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 8cff29f592..918018df63 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. 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,7 +37,7 @@ 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(); @@ -42,11 +46,13 @@ console.log(cloneScript.a); // output is true. console.log(cloneScript.b); // output is 2. 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. | @@ -59,9 +65,10 @@ When a field has no clone decorator, the engine resolves how to clone its value 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{ +class CustomScript extends Script { /** Entity reference.*/ target: Entity; @@ -86,38 +93,43 @@ console.log(cloneScript.target === cloneEntity.findByName("child")); // output i 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 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; 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, or engine runtime state (such as `UpdateFlagManager`) 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. + `@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. + ```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.*/ @assignmentClone - c:Vector3[] = [new Vector3(0,0,0)]; - + 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 @@ -125,8 +137,8 @@ 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(); @@ -136,18 +148,20 @@ console.log(cloneScript.b); // output is 2,assignmentClone is just assignment th 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 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: - 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, or engine runtime state 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. + - 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/zh/core/clone.mdx b/docs/zh/core/clone.mdx index e31453c648..bf82d2fb73 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) 方法即可完成实体以及附属组件的克隆。 + ```typescript const cloneEntity = entity.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,7 +37,7 @@ 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(); @@ -43,11 +46,13 @@ console.log(cloneScript.a); // output is true. console.log(cloneScript.b); // output is 2. console.log(cloneScript.c); // output is (1,1,1),且是一份独立拷贝 —— Vector3 属于数学值类型,默认深克隆。 ``` + ### 默认克隆规则 + 当字段没有添加任何克隆装饰器时,引擎会根据字段值的类型决定克隆方式: | 值类型 | 默认克隆行为 | -| :--- | :--- | +| :-- | :-- | | 基本类型(`number`、`string`、`boolean` 等) | 拷贝值。 | | 资产(`Texture`、`Mesh`、`Material`、`Sprite`、`Font` 等 `ReferResource` 类型) | 共享引用 —— 克隆体与源使用同一份资产。 | | `Entity` / `Component` 引用 | 自动重映射到克隆子树内对应的克隆对象;指向子树外的引用保持原引用不变。 | @@ -60,9 +65,10 @@ console.log(cloneScript.c); // output is (1,1,1),且是一份独立拷贝 — 一个类可以通过两种方式获得"默认深克隆"能力:继承 [DataObject](/apis/core/#DataObject)(数据类 —— 引擎逐字段克隆),或提供 `copyFrom` 方法(数学类这样的值类型 —— 引擎经由它拷贝)。两种方式都要求类型支持无参构造:当槽位上没有可复用的预置实例时,引擎会先 `new Type()` 裸构造一个再填充,无法无参构造的类型会直接报错。 因此在不加装饰器的情况下,普通数据对象和数组默认会得到独立的深拷贝,资产依旧共享,实体引用则自动重映射: + ```typescript // define a custom script -class CustomScript extends Script{ +class CustomScript extends Script { /** Entity reference.*/ target: Entity; @@ -87,38 +93,42 @@ console.log(cloneScript.target === cloneEntity.findByName("child")); // output i 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) | 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型,克隆体将与源共享同一个引用。 | -| [deepClone](/apis/core/#deepClone) | 克隆时深克隆字段的整棵子树。深意图会传播进成员;子树内的资产保持共享、实体引用重映射、运行时状态保留克隆体自己的。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产或引擎运行时状态(如 `UpdateFlagManager`)使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。| +| [deepClone](/apis/core/#deepClone) | 克隆时深克隆字段的整棵子树。深意图会传播进成员;子树内的资产保持共享、实体引用重映射、运行时状态保留克隆体自己的。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产或引擎运行时状态(如 `UpdateFlagManager`)使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。 | -`@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 `@deepClone`,希望共享就使用 `@assignmentClone`。 + `@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 + `@deepClone`,希望共享就使用 `@assignmentClone`。 我们将上面的案例稍加修改,分别对 `CustomScript` 中的四个字段添加了不同的“克隆装饰器“。由于数组默认就会被深克隆,我们对字段 `c` 使用 `assignmentClone` 来强制共享,对字段 `d` 使用 `deepClone` 显式声明默认行为,并增加了额外的打印输出进行进一步讲解。 + ```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.*/ @assignmentClone - c:Vector3[] = [new Vector3(0,0,0)]; - + 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 @@ -126,8 +136,8 @@ 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(); @@ -137,18 +147,20 @@ console.log(cloneScript.b); // output is 2,assignmentClone is just assignment th 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 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]. ``` -- 注意: + +- 注意: - 字段装饰器优先级最高,并且在克隆走字段遍历的任何地方对嵌套对象的字段同样生效。 - `deepClone` 会深克隆整棵子树:深意图会传播进成员,没有深克隆默认的成员类实例同样被拷贝(因此必须支持无参构造)。子树内的资产保持共享,实体引用被重映射,运行时状态保留克隆体自己的,嵌套字段上的装饰器依然优先。 - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用、资产或引擎运行时状态使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为,或使用资产自身的克隆 API 得到真正的拷贝。 - - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 + - 如果克隆装饰器不能满足诉求,可以通过实现 [\_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 ## 克隆与资产引用计数 + 克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数,因此销毁源对象不会让资产在克隆体脚下被回收。内置组件会在销毁时释放自己持有的引用;被自定义脚本字段引用的资产则由克隆体一直保活 —— 不再需要时,调用资产自身的 `destroy()` 释放。 From 118a7a5c724112c8d5ac85a88bd1810f7338128e Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 20 Jul 2026 00:17:15 +0800 Subject: [PATCH 108/109] fix(clone): throw on @deepClone decorating a function The last hole in honor-or-throw: pointing the decorator at a function silently shared the source's binding. Code is not a cloneable graph, so the assert now rejects it like entities, assets, and runtime state; a function reached by a propagated deep intent inside the subtree still shares, unthrown. Co-Authored-By: Claude Opus 4.8 --- docs/en/core/clone.mdx | 4 ++-- docs/zh/core/clone.mdx | 4 ++-- packages/core/src/clone/CloneManager.ts | 2 +- packages/core/src/clone/CloneUtil.ts | 6 +++++ tests/src/core/CloneManager.test.ts | 31 +++++++++++++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx index 918018df63..1d9d8de532 100644 --- a/docs/en/core/clone.mdx +++ b/docs/en/core/clone.mdx @@ -102,7 +102,7 @@ In addition to the default type-driven rules, the engine also provides "clone de | :-- | :-- | | [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, or engine runtime state (such as `UpdateFlagManager`) throws — remove the decorator to get the default behavior, or copy an asset via its own clone API. | +| [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 @@ -159,7 +159,7 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - 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, or engine runtime state 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. + - `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 diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx index bf82d2fb73..dc5ca73704 100644 --- a/docs/zh/core/clone.mdx +++ b/docs/zh/core/clone.mdx @@ -102,7 +102,7 @@ console.log(cloneScript.texture === script.texture); // output is true,资产 | :-- | :-- | | [ignoreClone](/apis/core/#ignoreClone) | 克隆时对字段进行忽略,克隆体保留自身构造时的值。 | | [assignmentClone](/apis/core/#assignmentClone) | 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型,克隆体将与源共享同一个引用。 | -| [deepClone](/apis/core/#deepClone) | 克隆时深克隆字段的整棵子树。深意图会传播进成员;子树内的资产保持共享、实体引用重映射、运行时状态保留克隆体自己的。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产或引擎运行时状态(如 `UpdateFlagManager`)使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。 | +| [deepClone](/apis/core/#deepClone) | 克隆时深克隆字段的整棵子树。深意图会传播进成员;子树内的资产保持共享、实体引用重映射、运行时状态保留克隆体自己的。引擎绑定的对象无法被深克隆:对 `Entity` / `Component` 引用、资产、引擎运行时状态(如 `UpdateFlagManager`)或函数使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。 | `@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用 @@ -158,7 +158,7 @@ console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use t - 字段装饰器优先级最高,并且在克隆走字段遍历的任何地方对嵌套对象的字段同样生效。 - `deepClone` 会深克隆整棵子树:深意图会传播进成员,没有深克隆默认的成员类实例同样被拷贝(因此必须支持无参构造)。子树内的资产保持共享,实体引用被重映射,运行时状态保留克隆体自己的,嵌套字段上的装饰器依然优先。 - - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用、资产或引擎运行时状态使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为,或使用资产自身的克隆 API 得到真正的拷贝。 + - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用、资产、引擎运行时状态或函数使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为,或使用资产自身的克隆 API 得到真正的拷贝。 - 如果克隆装饰器不能满足诉求,可以通过实现 [\_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。 ## 克隆与资产引用计数 diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts index c71afa97c1..0165be9914 100644 --- a/packages/core/src/clone/CloneManager.ts +++ b/packages/core/src/clone/CloneManager.ts @@ -5,7 +5,7 @@ import { CloneMode } from "./enums/CloneMode"; * 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 or an asset), cloning throws rather than silently falling back. + * entity reference, an asset, or a function), cloning throws rather than silently falling back. */ export function deepClone(target: object, propertyKey: string): void { CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep); diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index e7a1fcaea9..660b3fa23a 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -102,6 +102,12 @@ export class CloneUtil { * Throws when a `@deepClone`-decorated value cannot be deep cloned. */ static _assertDeepClonable(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 ` + diff --git a/tests/src/core/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts index 3dd5431911..3d3d6a6363 100644 --- a/tests/src/core/CloneManager.test.ts +++ b/tests/src/core/CloneManager.test.ts @@ -320,6 +320,12 @@ class AssignedContainerScript extends Script { 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 @@ -1226,6 +1232,31 @@ describe("Clone remap", async () => { }); 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"); From c61e0684e4c9befb688c743be02c6e11b64d6e8f Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 20 Jul 2026 00:24:23 +0800 Subject: [PATCH 109/109] refactor(clone): rename the assert and trim comments Co-Authored-By: Claude Opus 4.8 --- packages/core/src/clone/CloneUtil.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts index 660b3fa23a..cd06d6b451 100644 --- a/packages/core/src/clone/CloneUtil.ts +++ b/packages/core/src/clone/CloneUtil.ts @@ -43,9 +43,7 @@ export class CloneUtil { ): any { if (fieldMode === CloneMode.Assignment) return source; if (fieldMode === CloneMode.Deep) { - // The explicit decorator must be honorable on the value it decorates; the deep intent it - // propagates into the subtree is softer — engine-bound members keep their defaults. - CloneUtil._assertDeepClonable(source); + CloneUtil._assertDeepCloneable(source); forceDeepClone = true; } return CloneUtil._cloneByDefault(source, preset, cloneMap, forceDeepClone); @@ -53,9 +51,6 @@ export class CloneUtil { /** * @internal - * `forceDeepClone` (a `@deepClone` somewhere up the walk) deep-copies the whole subtree: a - * class with no deep default flips from "share" to "field-walk", and containers carry the - * force into their members. Engine-bound values keep their defaults (remap / share / own). */ static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any { if (typeof source === "function") return forceDeepClone ? source : typeof preset === "function" ? preset : source; @@ -99,9 +94,8 @@ export class CloneUtil { /** * @internal - * Throws when a `@deepClone`-decorated value cannot be deep cloned. */ - static _assertDeepClonable(source: any): void { + 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. ` + @@ -131,7 +125,6 @@ export class CloneUtil { /** * @internal - * Registers the new instance before the caller descends, so a cycle resolves to it. */ static _createCloneTarget(source: any, preset: any, cloneMap: Map): any { const ctor = (source).constructor;