diff --git a/packages/core/src/animation/AnimationClip.ts b/packages/core/src/animation/AnimationClip.ts index adb0c07c91..9f7b6779fc 100644 --- a/packages/core/src/animation/AnimationClip.ts +++ b/packages/core/src/animation/AnimationClip.ts @@ -204,6 +204,15 @@ export class AnimationClip extends EngineObject { this._length = 0; } + /** + * Samples an animation at a given time. + * @param entity - The animated entity + * @param time - The time to sample an animation + */ + sampleAnimation(entity: Entity, time: number): void { + this._sampleAnimation(entity, time); + } + /** * @internal * Samples an animation at a given time. diff --git a/packages/core/src/animation/AnimationClipCurveBinding.ts b/packages/core/src/animation/AnimationClipCurveBinding.ts index 906c154087..e6e4066181 100644 --- a/packages/core/src/animation/AnimationClipCurveBinding.ts +++ b/packages/core/src/animation/AnimationClipCurveBinding.ts @@ -33,7 +33,7 @@ export class AnimationClipCurveBinding { /** The animation curve. */ curve: AnimationCurve; - private _tempCurveOwner: Record> = {}; + private _tempCurveOwner = new WeakMap>(); /** * @internal @@ -63,10 +63,11 @@ export class AnimationClipCurveBinding { * @internal */ _getTempCurveOwner(entity: Entity, component: Component): AnimationCurveOwner { - const { instanceId } = entity; - if (!this._tempCurveOwner[instanceId]) { - this._tempCurveOwner[instanceId] = this._createCurveOwner(entity, component); + let owner = this._tempCurveOwner.get(entity); + if (!owner) { + owner = this._createCurveOwner(entity, component); + this._tempCurveOwner.set(entity, owner); } - return this._tempCurveOwner[instanceId]; + return owner; } } diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 42fc2ee3fa..4413f393a6 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -6,11 +6,11 @@ import { Renderer } from "../Renderer"; import { Script } from "../Script"; import { Logger } from "../base/Logger"; import { assignmentClone, ignoreClone } from "../clone/CloneManager"; +import { ClearableObjectPool } from "../utils/ClearableObjectPool"; import { AnimatorController } from "./AnimatorController"; import { AnimatorControllerLayer } from "./AnimatorControllerLayer"; import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter"; import { AnimatorState } from "./AnimatorState"; -import { AnimatorStateInstance } from "./AnimatorStateInstance"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; import { KeyframeValueType } from "./Keyframe"; @@ -19,6 +19,7 @@ import { AnimatorCullingMode } from "./enums/AnimatorCullingMode"; import { AnimatorLayerBlendingMode } from "./enums/AnimatorLayerBlendingMode"; import { AnimatorStatePlayState } from "./enums/AnimatorStatePlayState"; import { LayerState } from "./enums/LayerState"; +import { WrapMode } from "./enums/WrapMode"; import { AnimationCurveLayerOwner } from "./internal/AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; @@ -31,13 +32,15 @@ import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCur */ export class Animator extends Component { private static _passedTriggerParameterNames = new Array(); - private static _tempScripts: Script[] = []; /** Culling mode of this Animator. */ cullingMode: AnimatorCullingMode = AnimatorCullingMode.None; /** The playback speed of the Animator, 1.0 is normal playback speed. */ @assignmentClone speed = 1.0; + /** Whether the Animator sends AnimationEvent callbacks. */ + @assignmentClone + fireEvents = true; /** @internal */ _playFrameCount = -1; @@ -54,7 +57,9 @@ export class Animator extends Component { @ignoreClone private _animatorLayersData = new Array(); @ignoreClone - private _curveOwnerPool: Record>> = Object.create(null); + private _curveOwnerPool: WeakMap>> = new WeakMap(); + @ignoreClone + private _animationEventHandlerPool = new ClearableObjectPool(AnimationEventHandler); @ignoreClone private _parametersValueMap = >Object.create(null); @@ -114,7 +119,9 @@ export class Animator extends Component { * @param normalizedTimeOffset - The normalized time offset (between 0 and 1, default 0) to start the state's animation from */ play(stateName: string, layerIndex: number = -1, normalizedTimeOffset: number = 0): void { - this._resetIfControllerUpdated(); + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } const stateInfo = this._getAnimatorStateInfo(stateName, layerIndex); const { state } = stateInfo; @@ -189,7 +196,9 @@ export class Animator extends Component { return; } - this._resetIfControllerUpdated(); + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } this._updateMark++; @@ -201,34 +210,43 @@ export class Animator extends Component { } /** - * Get the state instance currently playing on the target layer. + * Get the playing state from the target layerIndex. * @param layerIndex - The layer index - * @returns The state instance, or null if nothing is playing - * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. */ - getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { - this._resetIfControllerUpdated(); - return this._animatorLayersData[layerIndex]?.srcPlayData?.instance ?? null; + getCurrentAnimatorState(layerIndex: number): AnimatorState { + return this._animatorLayersData[layerIndex]?.srcPlayData?.state; } /** - * Get the state instance for a named state on this Animator. - * Overrides on the returned instance only affect this Animator. + * Get the state by name. + * @param stateName - The state name + * @param layerIndex - The layer index(default -1). If layer is -1, find the first state with the given state name + */ + /** + * Find the per-instance play data for a state by name. + * The returned object's `speed` and `wrapMode` are per-instance and safe to modify without affecting other Animator instances. * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns The state instance, or null if no state matches - * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. + * @returns Per-instance AnimatorStatePlayData, or null if not found */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { - this._resetIfControllerUpdated(); + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData { const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); - if (!state) return null; - return this._getAnimatorLayerData(foundLayer).getOrCreateInstance(state); + if (!state || foundLayer < 0) return null; + const layerData = this._animatorLayersData[foundLayer]; + if (!layerData) return null; + // Check srcPlayData and destPlayData for the matching state + if (layerData.srcPlayData.state === state) return layerData.srcPlayData; + if (layerData.destPlayData.state === state) return layerData.destPlayData; + // State exists in controller but not currently playing — return srcPlayData initialized with the state + return layerData.srcPlayData; } /** * Get the layer by name. * @param name - The layer's name. + * @todo Return per-instance layer data (like AnimatorStatePlayData for states) instead of shared asset. + * Currently returns the shared AnimatorControllerLayer — modifying `weight` affects all instances. + * Should follow Unity's pattern: Animator.SetLayerWeight/GetLayerWeight (per-instance). */ findLayerByName(name: string): AnimatorControllerLayer { return this._animatorController?._layersMap[name]; @@ -316,30 +334,40 @@ export class Animator extends Component { * @internal */ _reset(): void { - const { _curveOwnerPool: animationCurveOwners } = this; - for (let instanceId in animationCurveOwners) { - const propertyOwners = animationCurveOwners[instanceId]; - for (let property in propertyOwners) { - const owner = propertyOwners[property]; - owner.revertDefaultValue(); + // revertDefaultValue 可能在死 target 上抛;listener 清理必须始终发生,否则 + // polyfill 的 clipChangedListener 会留在共享的 AnimatorState 上,捕获 this(Animator)→Entity 整树。 + // 用 try/catch 隔离 revert,listener 块紧跟其后无条件执行。 + const layersData = this._animatorLayersData; + for (let i = 0, n = layersData.length; i < n; i++) { + const stateDataMap = layersData[i].animatorStateDataMap; + for (const stateName in stateDataMap) { + const stateData = stateDataMap[stateName]; + const layerOwners = stateData.curveLayerOwner; + for (let j = 0, m = layerOwners.length; j < m; j++) { + try { + layerOwners[j]?.curveOwner?.revertDefaultValue(); + } catch (e) { + Logger.warn("Animator._reset: revertDefaultValue threw", e); + } + } + if (stateData.clipChangedListener && stateData.state) { + stateData.state._updateFlagManager.removeListener(stateData.clipChangedListener); + stateData.clipChangedListener = null; + stateData.state = null; + } } } this._animatorLayersData.length = 0; - this._curveOwnerPool = Object.create(null); + this._curveOwnerPool = new WeakMap(); this._parametersValueMap = Object.create(null); + this._animationEventHandlerPool.clear(); if (this._controllerUpdateFlag) { this._controllerUpdateFlag.flag = false; } } - private _resetIfControllerUpdated(): void { - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } - } - /** * @internal */ @@ -353,6 +381,7 @@ export class Animator extends Component { protected override _onDestroy(): void { super._onDestroy(); + this._reset(); const controller = this._animatorController; if (controller) { this._addResourceReferCount(controller, -1); @@ -367,12 +396,12 @@ export class Animator extends Component { normalizedTimeOffset: number, isFixedDuration: boolean ): void { - this._resetIfControllerUpdated(); + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } const { state, layerIndex: playLayerIndex } = this._getAnimatorStateInfo(stateName, layerIndex); - if (!state) { - return; - } + if (!state) return; const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); manuallyTransition.duration = duration; @@ -398,10 +427,8 @@ export class Animator extends Component { break; } } - } else if (layerIndex >= 0 && layerIndex < layers.length) { - state = layers[layerIndex].stateMachine.findStateByName(stateName); } else { - layerIndex = -1; + state = layers[layerIndex].stateMachine.findStateByName(stateName); } } stateInfo.layerIndex = layerIndex; @@ -410,18 +437,19 @@ export class Animator extends Component { } private _getAnimatorStateData( + stateName: string, animatorState: AnimatorState, animatorLayerData: AnimatorLayerData, layerIndex: number ): AnimatorStateData { const { animatorStateDataMap } = animatorLayerData; - let animatorStateData = animatorStateDataMap.get(animatorState); + let animatorStateData = animatorStateDataMap[stateName]; if (!animatorStateData) { - animatorStateData = new AnimatorStateData(animatorState); - animatorStateDataMap.set(animatorState, animatorStateData); + animatorStateData = new AnimatorStateData(); + animatorStateDataMap[stateName] = animatorStateData; this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); + this._saveAnimatorEventHandlers(animatorState, animatorStateData); } - this._ensureEventHandlers(animatorState, animatorStateData); return animatorStateData; } @@ -453,17 +481,20 @@ export class Animator extends Component { } const { property } = curve; - const { instanceId } = component; - // Get owner - const propertyOwners = (curveOwnerPool[instanceId] ||= >>( - Object.create(null) - )); + // Get owner — WeakMap keyed by Component so dead components let entries GC. + let propertyOwners = curveOwnerPool.get(component); + if (!propertyOwners) { + propertyOwners = >>Object.create(null); + curveOwnerPool.set(component, propertyOwners); + } const owner = (propertyOwners[property] ||= curve._createCurveOwner(targetEntity, component)); - // Get layer owner - const layerPropertyOwners = (layerCurveOwnerPool[instanceId] ||= >( - Object.create(null) - )); + // Get layer owner — same WeakMap-by-Component pattern (was Record which kept dead components pinned for the Animator's lifetime). + let layerPropertyOwners = layerCurveOwnerPool.get(component); + if (!layerPropertyOwners) { + layerPropertyOwners = >Object.create(null); + layerCurveOwnerPool.set(component, layerPropertyOwners); + } const layerOwner = (layerPropertyOwners[property] ||= curve._createCurveLayerOwner(owner)); if (mask && mask.pathMasks.length) { @@ -478,41 +509,36 @@ export class Animator extends Component { } } - private _ensureEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { - // state._updateFlagManager dispatches on both clip-swap and clip-events-mutation, - // so its version covers every input that affects eventHandlers binding - const stateVersion = state._updateFlagManager.version; - const scriptsVersion = this._entity._scriptsVersion; - if ( - animatorStateData.eventsBuiltVersion === stateVersion && - animatorStateData.eventsBuiltScriptsVersion === scriptsVersion - ) { - return; - } - - const scripts = Animator._tempScripts; - this._entity.getComponents(Script, scripts); - const scriptCount = scripts.length; - const { events } = state.clip; + private _saveAnimatorEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { + const eventHandlerPool = this._animationEventHandlerPool; + const scripts = []; const { eventHandlers } = animatorStateData; - eventHandlers.length = 0; - for (let i = 0, n = events.length; i < n; i++) { - const event = events[i]; - const eventHandler = new AnimationEventHandler(); - const funcName = event.functionName; - const { handlers } = eventHandler; - eventHandler.event = event; - for (let j = scriptCount - 1; j >= 0; j--) { - const script = scripts[j]; - const handler = script[funcName]?.bind(script); - handler && handlers.push(handler); + const clipChangedListener = () => { + this._entity.getComponents(Script, scripts); + const scriptCount = scripts.length; + const { events } = state.clip; + eventHandlers.length = 0; + for (let i = 0, n = events.length; i < n; i++) { + const event = events[i]; + const eventHandler = eventHandlerPool.get(); + const funcName = event.functionName; + const { handlers } = eventHandler; + + eventHandler.event = event; + handlers.length = 0; + for (let j = scriptCount - 1; j >= 0; j--) { + const script = scripts[j]; + const handler = script[funcName]?.bind(script); + handler && handlers.push(handler); + } + eventHandlers.push(eventHandler); } - eventHandlers.push(eventHandler); - } - scripts.length = 0; - animatorStateData.eventsBuiltVersion = stateVersion; - animatorStateData.eventsBuiltScriptsVersion = scriptsVersion; + }; + clipChangedListener(); + state._updateFlagManager.addListener(clipChangedListener); + animatorStateData.state = state; + animatorStateData.clipChangedListener = clipChangedListener; } private _clearCrossData(animatorLayerData: AnimatorLayerData): void { @@ -539,8 +565,8 @@ export class Animator extends Component { } private _prepareStandbyCrossFading(animatorLayerData: AnimatorLayerData): void { - // Standby have two sub state, one is never play (srcPlayData is null), one is finished (srcPlayData is non-null) - animatorLayerData.srcPlayData && this._prepareSrcCrossData(animatorLayerData, true); + // Standby have two sub state, one is never play, one is finished, never play srcPlayData.state is null + animatorLayerData.srcPlayData.state && this._prepareSrcCrossData(animatorLayerData, true); // Add dest cross curve data this._prepareDestCrossData(animatorLayerData, true); } @@ -632,9 +658,9 @@ export class Animator extends Component { aniUpdate: boolean ): void { const { srcPlayData } = layerData; - const state = srcPlayData.instance._state; + const { state } = srcPlayData; - const playSpeed = srcPlayData.instance.speed * this.speed; + const playSpeed = srcPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; srcPlayData.updateOrientation(playDeltaTime); @@ -717,8 +743,7 @@ export class Animator extends Component { ); if (transition) { - // Remove speed factor, use actual cost time. Per-instance speed=0 means the source - // state is paused, so it consumes no time — pass deltaTime through to the destination. + // Remove speed factor, use actual cost time const remainDeltaTime = playSpeed === 0 ? deltaTime : deltaTime - playCostTime / playSpeed; remainDeltaTime > 0 && this._updateState(layerData, remainDeltaTime, aniUpdate); } @@ -730,7 +755,7 @@ export class Animator extends Component { additive: boolean, aniUpdate: boolean ): void { - const curveBindings = playData.instance.clip._curveBindings; + const curveBindings = playData.state.clip._curveBindings; const finished = playData.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { @@ -764,20 +789,20 @@ export class Animator extends Component { ) { const { srcPlayData, destPlayData, layerIndex } = layerData; const { speed } = this; - const srcState = srcPlayData.instance._state; - const destState = destPlayData.instance._state; + const { state: srcState } = srcPlayData; + const { state: destState } = destPlayData; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { return; } - const srcPlaySpeed = srcPlayData.instance.speed * speed; - const dstPlaySpeed = destPlayData.instance.speed * speed; + const srcPlaySpeed = srcPlayData.speed * speed; + const dstPlaySpeed = destPlayData.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; - srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); - destPlayData.updateOrientation(dstPlayDeltaTime); + srcPlayData && srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); + destPlayData && destPlayData.updateOrientation(dstPlayDeltaTime); const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcPlayData; const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destPlayData; @@ -855,8 +880,8 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { _curveBindings: srcCurves } = srcPlayData.instance.clip; - const destState = destPlayData.instance._state; + const { _curveBindings: srcCurves } = srcPlayData.state.clip; + const { state: destState } = destPlayData; const { _curveBindings: destCurves } = destState.clip; const finished = destPlayData.playState === AnimatorStatePlayState.Finished; @@ -895,14 +920,14 @@ export class Animator extends Component { aniUpdate: boolean ) { const { destPlayData } = layerData; - const state = destPlayData.instance._state; + const { state } = destPlayData; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { return; } - const playSpeed = destPlayData.instance.speed * this.speed; + const playSpeed = destPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; destPlayData.updateOrientation(playDeltaTime); @@ -969,7 +994,7 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const state = destPlayData.instance._state; + const { state } = destPlayData; const { _curveBindings: curveBindings } = state.clip; const { clipTime: destClipTime, playState } = destPlayData; @@ -1007,8 +1032,8 @@ export class Animator extends Component { aniUpdate: boolean ): void { const playData = layerData.srcPlayData; - const state = playData.instance._state; - const actualSpeed = playData.instance.speed * this.speed; + const { state } = playData; + const actualSpeed = playData.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; playData.updateOrientation(actualDeltaTime); @@ -1049,7 +1074,7 @@ export class Animator extends Component { } const { curveLayerOwner } = playData.stateData; - const { _curveBindings: curveBindings } = playData.instance.clip; + const { _curveBindings: curveBindings } = playData.state.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; @@ -1070,13 +1095,14 @@ export class Animator extends Component { } else { layerData.layerState = LayerState.Playing; } - layerData.completeCrossFade(); + layerData.switchPlayData(); + layerData.crossFadeTransition = null; } private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { if (layerData.layerState === LayerState.Playing) { const srcPlayData = layerData.srcPlayData; - if (srcPlayData.instance._state !== playState) { + if (srcPlayData.state !== playState) { const { curveLayerOwner } = srcPlayData.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); @@ -1100,7 +1126,7 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): AnimatorStateTransition { - const state = playData.instance._state; + const { state } = playData; const clipDuration = state.clip.length; let targetTransition: AnimatorStateTransition = null; const startTime = state.clipStartTime * clipDuration; @@ -1317,23 +1343,19 @@ export class Animator extends Component { } private _preparePlay(state: AnimatorState, layerIndex: number, normalizedTimeOffset: number = 0): boolean { + const name = state.name; if (!state.clip) { - Logger.warn(`The state named ${state.name} has no AnimationClip data.`); + Logger.warn(`The state named ${name} has no AnimationClip data.`); return false; } const animatorLayerData = this._getAnimatorLayerData(layerIndex); - const animatorStateData = this._getAnimatorStateData(state, animatorLayerData, layerIndex); + const animatorStateData = this._getAnimatorStateData(name, state, animatorLayerData, layerIndex); this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - const playData = animatorLayerData.getOrCreateInstance(state)._playData; - playData.reset(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); - animatorLayerData.srcPlayData = playData; - // Drop any dangling cross-fade slot from a previously-interrupted crossFade - // so a later crossFade(B) isn't wrongly no-op'd by the active-dest guard. - animatorLayerData.clearCrossFadeSlot(); + animatorLayerData.srcPlayData.reset(state, animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); animatorLayerData.resetCurrentCheckIndex(); return true; @@ -1433,21 +1455,13 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(layerIndex); + const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); - // Self/active-dest cross-fade is a no-op: each state has one persistent - // instance per layer, so a second concurrent fade has nowhere to live. - if ( - animatorLayerData.srcPlayData?.instance._state === crossState || - animatorLayerData.destPlayData?.instance._state === crossState - ) { - return false; - } - - const animatorStateData = this._getAnimatorStateData(crossState, animatorLayerData, layerIndex); - - const destPlayData = animatorLayerData.getOrCreateInstance(crossState)._playData; - destPlayData.reset(animatorStateData, transition.offset * crossState._getClipActualEndTime()); - animatorLayerData.destPlayData = destPlayData; + animatorLayerData.destPlayData.reset( + crossState, + animatorStateData, + transition.offset * crossState._getClipActualEndTime() + ); animatorLayerData.resetCurrentCheckIndex(); switch (animatorLayerData.layerState) { @@ -1482,24 +1496,28 @@ export class Animator extends Component { lastClipTime: number, deltaTime: number ): void { - const { isForward, clipTime } = playData; - const state = playData.instance._state; + const { state, isForward, clipTime, wrapMode } = playData; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); + const canWrap = wrapMode === WrapMode.Loop; if (isForward) { if (lastClipTime + deltaTime >= endTime) { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, endTime); - playData.currentEventIndex = 0; - this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); + if (canWrap) { + playData.currentEventIndex = 0; + this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); + } } else { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } } else { if (lastClipTime + deltaTime <= startTime) { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, startTime); - playData.currentEventIndex = eventHandlers.length - 1; - this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); + if (canWrap) { + playData.currentEventIndex = eventHandlers.length - 1; + this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); + } } else { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } @@ -1595,12 +1613,10 @@ export class Animator extends Component { lastPlayState: AnimatorStatePlayState, deltaTime: number ) { - // Re-check whether the clip events/scripts changed since the last build — - // play()/crossFade() entry points already ensure on enter, but addEvent() - // or addComponent(Script) after play() must also flow through. - this._ensureEventHandlers(state, playData.stateData); const { eventHandlers } = playData.stateData; - eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); + this.fireEvents && + eventHandlers.length && + this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); if (lastPlayState === AnimatorStatePlayState.UnStarted) { state._callOnEnter(this, layerIndex); diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts deleted file mode 100644 index 9bcdfa0fb5..0000000000 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { AnimationClip } from "./AnimationClip"; -import { AnimatorState } from "./AnimatorState"; -import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; -import { WrapMode } from "./enums/WrapMode"; - -/** - * Per-Animator view of an `AnimatorState`. - * - * Override fields (speed, wrapMode) are scoped to this Animator; unset fields - * fall through to the underlying state asset. - */ -export class AnimatorStateInstance { - /** @internal */ - readonly _state: AnimatorState; - /** @internal */ - readonly _playData: AnimatorStatePlayData; - - private _speed: number | undefined; - private _wrapMode: WrapMode | undefined; - - /** - * The name of the underlying state. - */ - get name(): string { - return this._state.name; - } - - /** - * The animation clip of the underlying state. - */ - get clip(): AnimationClip { - return this._state.clip; - } - - /** - * The normalized clip start time of the underlying state. - */ - get clipStartTime(): number { - return this._state.clipStartTime; - } - - /** - * The normalized clip end time of the underlying state. - */ - get clipEndTime(): number { - return this._state.clipEndTime; - } - - /** - * Playback speed for this Animator; overrides the underlying state when set. - */ - get speed(): number { - return this._speed ?? this._state.speed; - } - - set speed(value: number) { - this._speed = value; - } - - /** - * Wrap mode for this Animator; overrides the underlying state when set. - */ - get wrapMode(): WrapMode { - return this._wrapMode ?? this._state.wrapMode; - } - - set wrapMode(value: WrapMode) { - this._wrapMode = value; - } - - /** @internal */ - constructor(state: AnimatorState) { - this._state = state; - this._playData = new AnimatorStatePlayData(this); - } -} diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 3200aab5df..7a2914280a 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -17,9 +17,9 @@ export class AnimatorStateMachine { /** * The state will be played automatically. - * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. Cleared to `null` if the state is removed via `removeState`. + * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. */ - defaultState: AnimatorState | null = null; + defaultState: AnimatorState; /** @internal */ _entryTransitionCollection = new AnimatorStateTransitionCollection(); @@ -68,11 +68,8 @@ export class AnimatorStateMachine { const index = this.states.indexOf(state); if (index > -1) { this.states.splice(index, 1); - delete this._statesMap[name]; - if (this.defaultState === state) { - this.defaultState = null; - } } + delete this._statesMap[name]; } /** diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index 1bbffce883..bd201a871f 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,7 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; -export { AnimatorStateInstance } from "./AnimatorStateInstance"; +export { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimationEventHandler.ts b/packages/core/src/animation/internal/AnimationEventHandler.ts index b3231e24bf..9124846f92 100644 --- a/packages/core/src/animation/internal/AnimationEventHandler.ts +++ b/packages/core/src/animation/internal/AnimationEventHandler.ts @@ -1,9 +1,11 @@ +import { IPoolElement } from "../../utils/ObjectPool"; import { AnimationEvent } from "../AnimationEvent"; - /** * @internal */ -export class AnimationEventHandler { +export class AnimationEventHandler implements IPoolElement { event: AnimationEvent; handlers: Function[] = []; + + dispose() {} } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index a3b2677c0b..959aadf208 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,11 +1,10 @@ +import { Component } from "../../Component"; import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; -import { AnimatorState } from "../AnimatorState"; -import { AnimatorStateInstance } from "../AnimatorStateInstance"; import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import type { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; /** * @internal @@ -13,36 +12,21 @@ import type { AnimatorStatePlayData } from "./AnimatorStatePlayData"; export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; - curveOwnerPool: Record> = Object.create(null); - animatorStateDataMap: WeakMap = new WeakMap(); - instanceMap: WeakMap = new WeakMap(); - srcPlayData: AnimatorStatePlayData | null = null; - destPlayData: AnimatorStatePlayData | null = null; + curveOwnerPool: WeakMap> = new WeakMap(); + animatorStateDataMap: Record = {}; + srcPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); + destPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; manuallyTransition: AnimatorStateTransition = new AnimatorStateTransition(); crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - getOrCreateInstance(state: AnimatorState): AnimatorStateInstance { - const map = this.instanceMap; - let instance = map.get(state); - if (!instance) { - instance = new AnimatorStateInstance(state); - map.set(state, instance); - } - return instance; - } - - completeCrossFade(): void { - this.srcPlayData = this.destPlayData; - this.destPlayData = null; - this.crossFadeTransition = null; - } - - clearCrossFadeSlot(): void { - this.destPlayData = null; - this.crossFadeTransition = null; + switchPlayData(): void { + const srcPlayData = this.destPlayData; + const switchTemp = this.srcPlayData; + this.srcPlayData = srcPlayData; + this.destPlayData = switchTemp; } resetCurrentCheckIndex(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 08d455ac38..42b50edd87 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -8,8 +8,6 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; export class AnimatorStateData { curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; - eventsBuiltVersion = -1; - eventsBuiltScriptsVersion = -1; - - constructor(readonly state: AnimatorState) {} + state: AnimatorState = null; + clipChangedListener: () => void = null; } diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index a4f2c80142..7adc90c2f4 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -1,36 +1,75 @@ -import { AnimatorStateInstance } from "../AnimatorStateInstance"; +import { AnimationClip } from "../AnimationClip"; +import { AnimatorState } from "../AnimatorState"; +import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; +import { StateMachineScript } from "../StateMachineScript"; import { AnimatorStateData } from "./AnimatorStateData"; /** - * @internal + * Per-instance runtime data for an AnimatorState. + * Proxies read-only properties from the shared AnimatorState asset, + * while providing per-instance mutable properties (e.g. speed, wrapMode). */ export class AnimatorStatePlayData { + /** @internal */ + state: AnimatorState; + /** @internal */ stateData: AnimatorStateData; + /** @internal */ + playedTime: number; + playState: AnimatorStatePlayState; + /** @internal */ + clipTime: number; + /** @internal */ + currentEventIndex: number; + /** @internal */ + isForward = true; + /** @internal */ + offsetFrameTime: number; + /** Per-instance speed. Initialized from AnimatorState.speed, safe to modify without affecting other instances. */ + speed: number = 1.0; + /** Per-instance wrap mode. Initialized from AnimatorState.wrapMode, safe to modify without affecting other instances. */ + wrapMode: WrapMode = WrapMode.Loop; - playedTime: number = 0; - playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; - clipTime: number = 0; - currentEventIndex: number = 0; - isForward: boolean = true; - offsetFrameTime: number = 0; + // ── Proxy properties from AnimatorState (read-only) ── - private _changedOrientation: boolean = false; + /** The name of the state. */ + get name(): string { + return this.state.name; + } - constructor(public readonly instance: AnimatorStateInstance) {} + /** The clip played by this state. */ + get clip(): AnimationClip { + return this.state.clip; + } - reset(stateData: AnimatorStateData, offsetFrameTime: number): void { - const state = this.instance._state; - this.stateData = stateData; - this.offsetFrameTime = offsetFrameTime; + /** The transitions going out of this state. */ + get transitions(): Readonly { + return this.state.transitions; + } + + /** + * Add a state machine script to the underlying AnimatorState. + */ + addStateMachineScript(scriptType: new () => T): T { + return this.state.addStateMachineScript(scriptType); + } + + private _changedOrientation = false; + + reset(state: AnimatorState, stateData: AnimatorStateData, offsetFrameTime: number): void { + this.state = state; this.playedTime = 0; + this.offsetFrameTime = offsetFrameTime; + this.stateData = stateData; this.playState = AnimatorStatePlayState.UnStarted; this.clipTime = state.clipStartTime * state.clip.length; this.currentEventIndex = 0; this.isForward = true; - this._changedOrientation = false; - state._transitionCollection.needResetCurrentCheckIndex = true; + this.speed = state.speed; + this.wrapMode = state.wrapMode; + this.state._transitionCollection.needResetCurrentCheckIndex = true; } updateOrientation(deltaTime: number): void { @@ -46,12 +85,11 @@ export class AnimatorStatePlayData { update(deltaTime: number): void { this.playedTime += deltaTime; - const instance = this.instance; - const state = instance._state; + const state = this.state; let time = this.playedTime + this.offsetFrameTime; const duration = state._getDuration(); this.playState = AnimatorStatePlayState.Playing; - if (instance.wrapMode === WrapMode.Loop) { + if (this.wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; } else { if (Math.abs(time) >= duration) { @@ -69,9 +107,8 @@ export class AnimatorStatePlayData { } } - private _correctTime(): void { - const state = this.instance._state; - // Reverse playback at clipTime=0 would step into negatives; jump to clipEnd. + private _correctTime() { + const { state } = this; if (this.clipTime === 0) { this.clipTime = state.clipEndTime * state.clip.length; } diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 3aff4b19ee..b1c4c5651b 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -1,3 +1,9 @@ +type ResumableAudioSource = { + _resumePendingPlayback(): void; + _suspendPlaybackForInterruption(): boolean; + _resumeInterruptedPlayback(): void; +}; + /** * Audio Manager for managing global audio context and settings. */ @@ -7,15 +13,22 @@ export class AudioManager { private static _context: AudioContext; private static _gainNode: GainNode; - private static _resumePromise: Promise = null; private static _needsUserGestureResume = false; + private static _pendingSources = new Set(); + private static _playingSources = new Set(); + private static _interruptedSources = new Set(); + private static _foregroundRestoreDelay = 300; + private static _foregroundRestoreTimer: number | undefined; + private static _hidden = false; + private static _eventsBound = false; /** * Suspend the audio context. * @returns A promise that resolves when the audio context is suspended */ static suspend(): Promise { - return AudioManager.getContext().suspend(); + AudioManager._suspendActiveSourcesForInterruption(); + return AudioManager._context?.suspend() ?? Promise.resolve(); } /** @@ -24,14 +37,48 @@ export class AudioManager { * @returns A promise that resolves when the audio context is resumed */ static resume(): Promise { - return (AudioManager._resumePromise ??= AudioManager.getContext() - .resume() - .then(() => { - AudioManager._needsUserGestureResume = false; - }) - .finally(() => { - AudioManager._resumePromise = null; - })); + const context = AudioManager._context; + if (!context) { + return Promise.resolve(); + } + if (context.state === "running") { + AudioManager._clearForegroundRestore(); + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._resumeInterruptedSources(); + return Promise.resolve(); + } + return context.resume().then(() => { + AudioManager._clearForegroundRestore(); + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._resumeInterruptedSources(); + }); + } + + /** @internal */ + static _registerPendingSource(source: ResumableAudioSource): void { + AudioManager._pendingSources.add(source); + } + + /** @internal */ + static _unregisterPendingSource(source: ResumableAudioSource): void { + AudioManager._pendingSources.delete(source); + } + + /** @internal */ + static _registerPlayingSource(source: ResumableAudioSource): void { + AudioManager._playingSources.add(source); + } + + /** @internal */ + static _unregisterPlayingSource(source: ResumableAudioSource): void { + AudioManager._playingSources.delete(source); + } + + /** @internal */ + static _unregisterInterruptedSource(source: ResumableAudioSource): void { + AudioManager._interruptedSources.delete(source); } /** @@ -41,11 +88,12 @@ export class AudioManager { let context = AudioManager._context; if (!context) { AudioManager._context = context = new window.AudioContext(); - document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); - // iOS Safari requires user gesture to resume AudioContext - document.addEventListener("touchstart", AudioManager._resumeAfterInterruption, { passive: true }); - document.addEventListener("touchend", AudioManager._resumeAfterInterruption, { passive: true }); - document.addEventListener("click", AudioManager._resumeAfterInterruption); + context.onstatechange = AudioManager._onContextStateChange; + if (!AudioManager._eventsBound) { + AudioManager._eventsBound = true; + AudioManager._bindLifecycleEvents(); + AudioManager._bindGestureEvents(); + } } return context; } @@ -70,22 +118,153 @@ export class AudioManager { return AudioManager.getContext().state === "running"; } - private static _onVisibilityChange(): void { - if (!document.hidden && AudioManager._playingCount > 0 && !AudioManager.isAudioContextRunning()) { - // iOS WKWebView WebKit bug(Triggered in LingGuang App): AudioContext may be in a "zombie" state where - // state reports "suspended" but resume() alone won't restart audio rendering. - // Calling suspend() first forces a clean internal state reset before user gesture triggers resume. - // Related: https://bugs.webkit.org/show_bug.cgi?id=263627 - AudioManager.suspend(); - AudioManager._needsUserGestureResume = true; + private static _onContextStateChange(): void { + if (AudioManager._context?.state === "running") { + if (AudioManager._hidden || AudioManager._needsUserGestureResume) { + return; + } + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._resumeInterruptedSources(); + } + } + + private static _resumePendingSources(): void { + if (!AudioManager._pendingSources.size || !AudioManager.isAudioContextRunning()) { + return; + } + + const pendingSources = Array.from(AudioManager._pendingSources); + AudioManager._pendingSources.clear(); + + for (let i = 0, n = pendingSources.length; i < n; i++) { + pendingSources[i]._resumePendingPlayback(); + } + } + + private static _suspendActiveSourcesForInterruption(): void { + if (!AudioManager._playingSources.size) { + return; + } + + const playingSources = Array.from(AudioManager._playingSources); + for (let i = 0, n = playingSources.length; i < n; i++) { + const source = playingSources[i]; + if (source._suspendPlaybackForInterruption()) { + AudioManager._interruptedSources.add(source); + } + } + } + + private static _resumeInterruptedSources(): void { + if (!AudioManager._interruptedSources.size || !AudioManager.isAudioContextRunning()) { + return; + } + + const interruptedSources = Array.from(AudioManager._interruptedSources); + AudioManager._interruptedSources.clear(); + + for (let i = 0, n = interruptedSources.length; i < n; i++) { + interruptedSources[i]._resumeInterruptedPlayback(); + } + } + + private static _bindLifecycleEvents(): void { + const hiddenProp = AudioManager._getHiddenProp(); + const visibilityEvents = [ + "visibilitychange", + "mozvisibilitychange", + "msvisibilitychange", + "webkitvisibilitychange", + "qbrowserVisibilityChange" + ]; + + for (let i = 0, n = visibilityEvents.length; i < n; i++) { + document.addEventListener(visibilityEvents[i], (event) => { + const hidden = hiddenProp ? Boolean((document as any)[hiddenProp] || (event as any)?.hidden) : document.hidden; + hidden ? AudioManager._onHidden() : AudioManager._onShown(); + }); + } + + window.addEventListener("pagehide", AudioManager._onHidden); + window.addEventListener("pageshow", AudioManager._onShown); + document.addEventListener("pagehide", AudioManager._onHidden); + document.addEventListener("pageshow", AudioManager._onShown); + } + + private static _bindGestureEvents(): void { + const gestureEvents = ["pointerdown", "pointerup", "touchstart", "touchend", "mouseup", "click"]; + for (let i = 0, n = gestureEvents.length; i < n; i++) { + document.addEventListener(gestureEvents[i], AudioManager._resumeAfterInterruption, { passive: true }); + } + } + + private static _getHiddenProp(): string { + const doc = document as any; + if (typeof doc.hidden !== "undefined") return "hidden"; + if (typeof doc.mozHidden !== "undefined") return "mozHidden"; + if (typeof doc.msHidden !== "undefined") return "msHidden"; + if (typeof doc.webkitHidden !== "undefined") return "webkitHidden"; + return ""; + } + + private static _hasResumeWork(): boolean { + return ( + AudioManager._needsUserGestureResume || + AudioManager._pendingSources.size > 0 || + AudioManager._interruptedSources.size > 0 + ); + } + + private static _onHidden(): void { + if (AudioManager._hidden) { + return; + } + AudioManager._hidden = true; + AudioManager._clearForegroundRestore(); + AudioManager.suspend().catch(() => {}); + } + + private static _onShown(): void { + if (!AudioManager._hidden) { + return; + } + AudioManager._hidden = false; + + if (AudioManager._hasResumeWork()) { + AudioManager._prepareGestureResume(); + AudioManager._scheduleForegroundRestore(); } } private static _resumeAfterInterruption(): void { - if (AudioManager._needsUserGestureResume) { + if (AudioManager._hasResumeWork()) { AudioManager.resume().catch((e) => { console.warn("Failed to resume AudioContext:", e); }); } } + + private static _scheduleForegroundRestore(): void { + AudioManager._clearForegroundRestore(); + AudioManager._foregroundRestoreTimer = window.setTimeout(() => { + AudioManager._foregroundRestoreTimer = undefined; + AudioManager.resume().catch(() => AudioManager._prepareGestureResume()); + }, AudioManager._foregroundRestoreDelay); + } + + private static _clearForegroundRestore(): void { + if (AudioManager._foregroundRestoreTimer === undefined) { + return; + } + window.clearTimeout(AudioManager._foregroundRestoreTimer); + AudioManager._foregroundRestoreTimer = undefined; + } + + private static _prepareGestureResume(): Promise { + // iOS WKWebView may report a resumable state while rendering is still frozen. + // Force a clean context edge, then let a gesture or foreground retry restore sources. + AudioManager._needsUserGestureResume = true; + return AudioManager.suspend().catch(() => {}); + } } diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index 29a3ed8bc1..3dacf53ec3 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -159,27 +159,11 @@ export class AudioSource extends Component { if (AudioManager.isAudioContextRunning()) { this._startPlayback(); } else { - // iOS Safari requires resume() to be called within the same user gesture callback that triggers playback. - // Document-level events won't work - must call resume() directly here in play(). this._pendingPlay = true; - AudioManager.resume().then( - () => { - // Check if cancelled by stop()/pause() - if (!this._pendingPlay) { - return; - } - this._pendingPlay = false; - // Check if still valid to play after async resume - if (this._destroyed || !this.enabled || !this._clip) { - return; - } - this._startPlayback(); - }, - (e) => { - this._pendingPlay = false; - console.warn("Failed to resume AudioContext:", e); - } - ); + AudioManager._registerPendingSource(this); + AudioManager.resume().catch((e) => { + console.warn("Failed to resume AudioContext:", e); + }); } } @@ -187,23 +171,27 @@ export class AudioSource extends Component { * Stops playing the clip. */ stop(): void { - this._pendingPlay = false; + this._cancelPendingPlayback(); + AudioManager._unregisterInterruptedSource(this); if (this._isPlaying) { this._clearSourceNode(); this._isPlaying = false; - this._pausedTime = -1; - this._playTime = -1; AudioManager._playingCount--; + AudioManager._unregisterPlayingSource(this); } + + this._pausedTime = -1; + this._playTime = -1; } /** * Pauses playing the clip. */ pause(): void { - this._pendingPlay = false; + this._cancelPendingPlayback(); + AudioManager._unregisterInterruptedSource(this); if (this._isPlaying) { this._clearSourceNode(); @@ -211,6 +199,7 @@ export class AudioSource extends Component { this._pausedTime = AudioManager.getContext().currentTime; this._isPlaying = false; AudioManager._playingCount--; + AudioManager._unregisterPlayingSource(this); } } @@ -250,34 +239,120 @@ export class AudioSource extends Component { this.stop(); } + /** @internal */ + _resumePendingPlayback(): void { + if (!this._pendingPlay) { + return; + } + + this._pendingPlay = false; + + if (this._destroyed || !this.enabled || !this._clip?._getAudioSource()) { + return; + } + + this._startPlayback(); + } + + /** @internal */ + _suspendPlaybackForInterruption(): boolean { + if (!this._isPlaying) { + return false; + } + + const pausedTime = AudioManager.getContext().currentTime; + this._clearSourceNode(); + + this._pausedTime = pausedTime; + this._isPlaying = false; + AudioManager._playingCount--; + AudioManager._unregisterPlayingSource(this); + + return true; + } + + /** @internal */ + _resumeInterruptedPlayback(): void { + if ( + this._destroyed || + !this.enabled || + this._isPlaying || + this._pendingPlay || + !this._clip?._getAudioSource() || + this._playTime < 0 + ) { + return; + } + + if (AudioManager.isAudioContextRunning()) { + this._startPlayback(); + } else { + this._pendingPlay = true; + AudioManager._registerPendingSource(this); + } + } + private _startPlayback(): void { const startTime = this._pausedTime > 0 ? this._pausedTime - this._playTime : 0; - this._initSourceNode(startTime); + if (!this._initSourceNode(startTime)) { + this._pausedTime = -1; + this._playTime = -1; + return; + } this._playTime = AudioManager.getContext().currentTime - startTime; this._pausedTime = -1; this._isPlaying = true; AudioManager._playingCount++; + AudioManager._registerPlayingSource(this); } - private _initSourceNode(startTime: number): void { + private _initSourceNode(startTime: number): boolean { const context = AudioManager.getContext(); const sourceNode = context.createBufferSource(); + const audioBuffer = this._clip._getAudioSource(); + const duration = audioBuffer.duration; + let offset = Math.max(0, startTime); + + if (duration > 0) { + if (this._loop) { + offset %= duration; + } else if (offset >= duration) { + return false; + } + } - sourceNode.buffer = this._clip._getAudioSource(); + sourceNode.buffer = audioBuffer; sourceNode.playbackRate.value = this._playbackRate; sourceNode.loop = this._loop; sourceNode.onended = this._onPlayEnd; this._sourceNode = sourceNode; sourceNode.connect(this._gainNode); - sourceNode.start(0, startTime); + sourceNode.start(0, offset); + return true; } private _clearSourceNode(): void { - this._sourceNode.stop(); - this._sourceNode.disconnect(); - this._sourceNode.onended = null; + const sourceNode = this._sourceNode; + if (!sourceNode) { + return; + } + + sourceNode.onended = null; + try { + sourceNode.stop(); + } catch {} + sourceNode.disconnect(); this._sourceNode = null; } + + private _cancelPendingPlayback(): void { + if (!this._pendingPlay) { + return; + } + + this._pendingPlay = false; + AudioManager._unregisterPendingSource(this); + } } diff --git a/packages/core/src/physics/CharacterController.ts b/packages/core/src/physics/CharacterController.ts index f3c9e6f048..ea6961b70f 100644 --- a/packages/core/src/physics/CharacterController.ts +++ b/packages/core/src/physics/CharacterController.ts @@ -1,5 +1,5 @@ import { ICharacterController } from "@galacean/engine-design"; -import { Vector3 } from "@galacean/engine-math"; +import { Quaternion, Vector3 } from "@galacean/engine-math"; import { Engine } from "../Engine"; import { Entity } from "../Entity"; import { Collider } from "./Collider"; @@ -162,6 +162,10 @@ export class CharacterController extends Collider { (this._nativeCollider).setSlopeLimit(this._slopeLimit); } + protected override _teleportToEntityTransform(worldPosition: Vector3, _worldRotation: Quaternion): void { + (this._nativeCollider).setWorldPosition(worldPosition); + } + private _syncWorldPositionFromPhysicalSpace(): void { (this._nativeCollider).getWorldPosition(this.entity.transform.worldPosition); } diff --git a/packages/core/src/physics/Collider.ts b/packages/core/src/physics/Collider.ts index a8a591fe56..5afa164403 100644 --- a/packages/core/src/physics/Collider.ts +++ b/packages/core/src/physics/Collider.ts @@ -1,4 +1,5 @@ import { ICollider, IStaticCollider } from "@galacean/engine-design"; +import { Quaternion, Vector3 } from "@galacean/engine-math"; import { BoolUpdateFlag } from "../BoolUpdateFlag"; import { deepClone, ignoreClone } from "../clone/CloneManager"; import { ICustomClone } from "../clone/ComponentCloner"; @@ -28,6 +29,16 @@ export class Collider extends Component implements ICustomClone { protected _shapes: ColliderShape[] = []; protected _collisionLayerIndex: number = 0; + /** + * Disabling a collider only detaches its native actor from the simulation + * scene; the actor is not destroyed, so on re-enable it still holds its + * pre-disable pose. The first transform sync after (re-)enable must teleport, + * never sweep — otherwise a kinematic actor drags across the scene from that + * stale pose and fires spurious contacts along the path. + */ + @ignoreClone + private _pendingReenterTeleport: boolean = false; + /** * The shapes of this collider. */ @@ -108,20 +119,29 @@ export class Collider extends Component implements ICustomClone { * @internal */ _onUpdate(): void { - if (this._updateFlag.flag) { + const shapes = this._shapes; + if (this._pendingReenterTeleport || this._updateFlag.flag) { const { transform } = this.entity; - (this._nativeCollider).setWorldTransform( - transform.worldPosition, - transform.worldRotationQuaternion - ); + if (this._pendingReenterTeleport) { + this._teleportToEntityTransform(transform.worldPosition, transform.worldRotationQuaternion); + this._pendingReenterTeleport = false; + } else { + this._syncEntityTransformToNative(transform.worldPosition, transform.worldRotationQuaternion); + } const worldScale = transform.lossyWorldScale; - const shapes = this._shapes; for (let i = 0, n = shapes.length; i < n; i++) { shapes[i]._nativeShape?.setWorldScale(worldScale); } this._updateFlag.flag = false; } + + // Drive per-shape physics update (e.g. MeshColliderShape retries pending + // native shape creation when mesh data becomes accessible asynchronously). + // No-op for shape types that don't override `_onPhysicsUpdate`. + for (let i = 0, n = shapes.length; i < n; i++) { + shapes[i]._onPhysicsUpdate(); + } } /** @@ -134,6 +154,7 @@ export class Collider extends Component implements ICustomClone { */ override _onEnableInScene(): void { this.scene.physics._addCollider(this); + this._pendingReenterTeleport = true; } /** @@ -164,6 +185,32 @@ export class Collider extends Component implements ICustomClone { this._addNativeShape(this.shapes[i]); } this._setCollisionLayer(); + // Teleport native actor to entity's current world pose. + // The native actor was created in constructor() with the entity's then-current + // worldPosition/Rotation. On clone, the entity's transform fields are deep-cloned + // AFTER the Component (and its native actor) are constructed, so the native actor's + // pose lags behind the cloned entity transform until this sync. + const { transform } = this.entity; + this._teleportToEntityTransform(transform.worldPosition, transform.worldRotationQuaternion); + } + + /** + * Teleport native actor to a world pose (instant, no implied velocity). + * Used during initialization paths (clone) where the native actor must be re-aligned + * with the entity transform after construction-time pose was based on stale defaults. + */ + protected _teleportToEntityTransform(worldPosition: Vector3, worldRotation: Quaternion): void { + (this._nativeCollider).setWorldTransform(worldPosition, worldRotation); + } + + /** + * Sync entity world transform to native actor for per-frame updates. + * Default semantics: teleport (setGlobalPose). Subclasses override to express + * physics-aware movement (e.g. DynamicCollider routes kinematic actors through + * setKinematicTarget to generate contact events on swept motion). + */ + protected _syncEntityTransformToNative(worldPosition: Vector3, worldRotation: Quaternion): void { + (this._nativeCollider).setWorldTransform(worldPosition, worldRotation); } /** diff --git a/packages/core/src/physics/Collision.ts b/packages/core/src/physics/Collision.ts index 16edfd5bb3..440864e4fe 100644 --- a/packages/core/src/physics/Collision.ts +++ b/packages/core/src/physics/Collision.ts @@ -29,8 +29,7 @@ export class Collision { */ getContacts(outContacts: ContactPoint[]): number { const nativeCollision = this._nativeCollision; - const smallerShapeId = Math.min(nativeCollision.shape0Id, nativeCollision.shape1Id); - const factor = this.shape.id === smallerShapeId ? 1 : -1; + const factor = this.shape.id === nativeCollision.shape1Id ? 1 : -1; const nativeContactPoints = nativeCollision.getContacts(); const length = nativeContactPoints.size(); for (let i = 0; i < length; i++) { diff --git a/packages/core/src/physics/DynamicCollider.ts b/packages/core/src/physics/DynamicCollider.ts index 380a3b927f..debf078b14 100644 --- a/packages/core/src/physics/DynamicCollider.ts +++ b/packages/core/src/physics/DynamicCollider.ts @@ -13,6 +13,8 @@ import { MeshColliderShape } from "./shape/MeshColliderShape"; */ export class DynamicCollider extends Collider { private static _tempVector3 = new Vector3(); + private static _tempVector3_1 = new Vector3(); + private static _tempVector3_2 = new Vector3(); private static _tempQuat = new Quaternion(); private _linearDamping = 0; @@ -33,7 +35,9 @@ export class DynamicCollider extends Collider { private _isKinematic = false; private _constraints: DynamicColliderConstraints = 0; private _collisionDetectionMode: CollisionDetectionMode = CollisionDetectionMode.Discrete; - private _sleepThreshold = 5e-3; + private _kinematicTransformSyncMode: DynamicColliderKinematicTransformSyncMode = + DynamicColliderKinematicTransformSyncMode.Target; + private _sleepThreshold: number | undefined; private _automaticCenterOfMass = true; private _automaticInertiaTensor = true; @@ -223,7 +227,7 @@ export class DynamicCollider extends Collider { * The mass-normalized energy threshold, below which objects start going to sleep. */ get sleepThreshold(): number { - return this._sleepThreshold; + return this._sleepThreshold ?? Engine._nativePhysics?.getDefaultSleepThreshold?.() ?? 5e-3; } set sleepThreshold(value: number) { @@ -325,6 +329,22 @@ export class DynamicCollider extends Collider { } } + /** + * Controls how entity transform changes are synchronized to a kinematic native actor. + * + * @remarks + * `Target` routes transform changes through {@link move}, so PhysX treats the + * actor as moving between frames and can generate swept contacts. `Teleport` + * writes the native pose directly and does not imply velocity. + */ + get kinematicTransformSyncMode(): DynamicColliderKinematicTransformSyncMode { + return this._kinematicTransformSyncMode; + } + + set kinematicTransformSyncMode(value: DynamicColliderKinematicTransformSyncMode) { + this._kinematicTransformSyncMode = value; + } + /** * @internal */ @@ -367,6 +387,33 @@ export class DynamicCollider extends Collider { this._phasedActiveInScene && (this._nativeCollider).addTorque(torque); } + /** + * Apply a force to the DynamicCollider at a given position in world space. + * The force generates both linear acceleration through the center of mass and angular + * acceleration about it (torque = (position - centerOfMass) × force). + * @param force - The force to apply, in world space + * @param position - The position where the force is applied, in world space + */ + applyForceAtPosition(force: Vector3, position: Vector3): void { + if (!this._phasedActiveInScene) return; + const nativeCollider = this._nativeCollider; + + const localCoM = DynamicCollider._tempVector3; + nativeCollider.getCenterOfMass(localCoM); + + const transform = this.entity.transform; + const worldCoM = DynamicCollider._tempVector3_1; + Vector3.transformByQuat(localCoM, transform.worldRotationQuaternion, worldCoM); + worldCoM.add(transform.worldPosition); + + const torque = DynamicCollider._tempVector3_2; + Vector3.subtract(position, worldCoM, torque); + Vector3.cross(torque, force, torque); + + nativeCollider.addForce(force); + nativeCollider.addTorque(torque); + } + /** * Moves the kinematic collider to the specified position. * @remarks Only available when {@link isKinematic} is true. @@ -433,6 +480,30 @@ export class DynamicCollider extends Collider { super.addShape(shape); } + /** + * Route per-frame entity → native transform sync to the correct physics API based + * on kinematic state. + * + * PhysX 4.x docs (PxRigidDynamic): + * "If you intend to move a kinematic actor with [setGlobalPose] and want + * collision detection, use setKinematicTarget() instead." + * + * setGlobalPose is a teleport: PhysX skips contact detection between the old + * and new pose. setKinematicTarget tells PhysX the actor is animating to the + * target during the next simulate(), enabling swept contacts. Some compatibility + * layers need transform writes to stay teleport-like, so the sync mode is + * explicit while {@link move} always keeps target semantics. + * + * @internal + */ + protected override _syncEntityTransformToNative(worldPosition: Vector3, worldRotation: Quaternion): void { + if (this._isKinematic && this._kinematicTransformSyncMode === DynamicColliderKinematicTransformSyncMode.Target) { + (this._nativeCollider).move(worldPosition, worldRotation); + } else { + super._syncEntityTransformToNative(worldPosition, worldRotation); + } + } + /** * @internal */ @@ -460,6 +531,7 @@ export class DynamicCollider extends Collider { target._angularVelocity.copyFrom(this.angularVelocity); target._centerOfMass.copyFrom(this.centerOfMass); target._inertiaTensor.copyFrom(this.inertiaTensor); + target._kinematicTransformSyncMode = this._kinematicTransformSyncMode; super._cloneTo(target); } @@ -489,7 +561,9 @@ export class DynamicCollider extends Collider { } (this._nativeCollider).setMaxAngularVelocity(this._maxAngularVelocity); (this._nativeCollider).setMaxDepenetrationVelocity(this._maxDepenetrationVelocity); - (this._nativeCollider).setSleepThreshold(this._sleepThreshold); + if (this._sleepThreshold !== undefined) { + (this._nativeCollider).setSleepThreshold(this._sleepThreshold); + } (this._nativeCollider).setSolverIterations(this._solverIterations); (this._nativeCollider).setUseGravity(this._useGravity); (this._nativeCollider).setIsKinematic(this._isKinematic); @@ -555,6 +629,16 @@ export enum CollisionDetectionMode { ContinuousSpeculative } +/** + * Kinematic transform synchronization mode. + */ +export enum DynamicColliderKinematicTransformSyncMode { + /** Synchronize transform changes through PhysX setKinematicTarget. */ + Target, + /** Synchronize transform changes by directly teleporting the native actor. */ + Teleport +} + /** * Use these flags to constrain motion of dynamic collider. */ diff --git a/packages/core/src/physics/PhysicsMaterial.ts b/packages/core/src/physics/PhysicsMaterial.ts index 3ac16e66d8..aa22bc8c24 100644 --- a/packages/core/src/physics/PhysicsMaterial.ts +++ b/packages/core/src/physics/PhysicsMaterial.ts @@ -1,6 +1,7 @@ import { IPhysicsMaterial } from "@galacean/engine-design"; import { Engine } from "../Engine"; import { PhysicsMaterialCombineMode } from "./enums/PhysicsMaterialCombineMode"; +import { ignoreClone } from "../clone/CloneManager"; /** * Material class to represent a set of surface properties. @@ -14,6 +15,7 @@ export class PhysicsMaterial { private _destroyed: boolean; /** @internal */ + @ignoreClone _nativeMaterial: IPhysicsMaterial; constructor() { @@ -21,8 +23,8 @@ export class PhysicsMaterial { this._staticFriction, this._dynamicFriction, this._bounciness, - this._bounceCombine, - this._frictionCombine + this._frictionCombine, + this._bounceCombine ); } @@ -103,4 +105,23 @@ export class PhysicsMaterial { !this._destroyed && this._nativeMaterial.destroy(); this._destroyed = true; } + + /** + * @internal + */ + _cloneTo(target: PhysicsMaterial): void { + target._syncNative(); + } + + /** + * @internal + */ + _syncNative(): void { + const nativeMaterial = this._nativeMaterial; + nativeMaterial.setStaticFriction(this._staticFriction); + nativeMaterial.setDynamicFriction(this._dynamicFriction); + nativeMaterial.setBounciness(this._bounciness); + nativeMaterial.setFrictionCombine(this._frictionCombine); + nativeMaterial.setBounceCombine(this._bounceCombine); + } } diff --git a/packages/core/src/physics/PhysicsScene.ts b/packages/core/src/physics/PhysicsScene.ts index ce6bf2e4e8..5fccc08246 100644 --- a/packages/core/src/physics/PhysicsScene.ts +++ b/packages/core/src/physics/PhysicsScene.ts @@ -646,6 +646,7 @@ export class PhysicsScene { for (let i = 0; i < step; i++) { componentsManager.callScriptOnPhysicsUpdate(); this._callColliderOnUpdate(); + nativePhysicsManager.setContactEventEnabled(this._hasCollisionEventConsumers()); nativePhysicsManager.update(fixedTimeStep); this._callColliderOnLateUpdate(); this._dispatchEvents(nativePhysicsManager.updateEvents()); @@ -825,6 +826,28 @@ export class PhysicsScene { } } + private _hasCollisionEventConsumers(): boolean { + const { _elements: colliders } = this._colliders; + const { onCollisionEnter, onCollisionExit, onCollisionStay } = Script.prototype; + + for (let i = this._colliders.length - 1; i >= 0; --i) { + const scripts = colliders[i].entity._scripts; + const scriptElements = scripts._elements; + for (let j = scripts.length - 1; j >= 0; --j) { + const script = scriptElements[j]; + if ( + script.onCollisionEnter !== onCollisionEnter || + script.onCollisionExit !== onCollisionExit || + script.onCollisionStay !== onCollisionStay + ) { + return true; + } + } + } + + return false; + } + private _setGravity(): void { this._nativePhysicsScene.setGravity(this._gravity); } diff --git a/packages/core/src/physics/index.ts b/packages/core/src/physics/index.ts index 537bd367d6..f2d4cdf576 100644 --- a/packages/core/src/physics/index.ts +++ b/packages/core/src/physics/index.ts @@ -1,6 +1,11 @@ export { CharacterController } from "./CharacterController"; export { Collider } from "./Collider"; -export { CollisionDetectionMode, DynamicCollider, DynamicColliderConstraints } from "./DynamicCollider"; +export { + CollisionDetectionMode, + DynamicCollider, + DynamicColliderConstraints, + DynamicColliderKinematicTransformSyncMode +} from "./DynamicCollider"; export { HitResult } from "./HitResult"; export { PhysicsMaterial } from "./PhysicsMaterial"; export { PhysicsScene } from "./PhysicsScene"; diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts index 2ac82319fd..e74736ff64 100644 --- a/packages/core/src/physics/shape/ColliderShape.ts +++ b/packages/core/src/physics/shape/ColliderShape.ts @@ -27,7 +27,7 @@ export abstract class ColliderShape implements ICustomClone { private _rotation: Vector3 = new Vector3(); @deepClone private _position: Vector3 = new Vector3(); - private _contactOffset: number = 0.02; + private _contactOffset: number | undefined; /** * @internal @@ -55,7 +55,7 @@ export abstract class ColliderShape implements ICustomClone { * @defaultValue 0.02 */ get contactOffset(): number { - return this._contactOffset; + return this._contactOffset ?? Engine._nativePhysics?.getDefaultContactOffset?.() ?? 0.02; } set contactOffset(value: number) { @@ -168,6 +168,18 @@ export abstract class ColliderShape implements ICustomClone { target._syncNative(); } + /** + * @internal + * + * Called once per physics update tick by `Collider._onUpdate`. Base no-op. + * + * Subclasses can override for frame-driven maintenance. Currently used by + * `MeshColliderShape` to retry native shape creation when mesh data becomes + * accessible later or PhysX cooking previously failed due to transient state + * (async resource loading, cook pipeline warmup, etc.). + */ + _onPhysicsUpdate(): void {} + /** * @internal */ @@ -183,7 +195,9 @@ export abstract class ColliderShape implements ICustomClone { if (!this._nativeShape) return; this._nativeShape.setPosition(this._position); this._nativeShape.setRotation(this._rotation); - this._nativeShape.setContactOffset(this._contactOffset); + if (this._contactOffset !== undefined) { + this._nativeShape.setContactOffset(this._contactOffset); + } this._nativeShape.setIsTrigger(this._isTrigger); this._nativeShape.setMaterial(this._material._nativeMaterial); diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts index 51a46ab0e0..83935fe10c 100644 --- a/packages/core/src/physics/shape/MeshColliderShape.ts +++ b/packages/core/src/physics/shape/MeshColliderShape.ts @@ -16,6 +16,12 @@ export class MeshColliderShape extends ColliderShape { private _indices: Uint8Array | Uint16Array | Uint32Array | null = null; private _cookingFlags = MeshColliderShapeCookingFlag.Cleaning | MeshColliderShapeCookingFlag.VertexWelding; private _isShapeAttached = false; + /** + * `true` if a native shape creation was attempted but failed (mesh data not yet + * accessible, PhysX cooking transient failure, etc.). The `_onPhysicsUpdate` hook + * will keep retrying every frame until creation succeeds. + */ + private _pendingNativeShapeCreation = false; /** * Cooking flags for this mesh collider shape. @@ -74,15 +80,25 @@ export class MeshColliderShape extends ColliderShape { this._mesh?._addReferCount(-1); value?._addReferCount(1); this._mesh = value; - if (value && this._extractMeshData(value)) { - if (this._nativeShape) { - this._updateNativeShapeData(); + if (value) { + if (this._extractMeshData(value)) { + if (this._nativeShape) { + this._updateNativeShapeData(); + } else { + this._createNativeShape(); + } + // _createNativeShape can fail silently (cookMesh transient failure); mark pending if so + this._pendingNativeShapeCreation = !this._nativeShape; } else { - this._createNativeShape(); + // Mesh not yet accessible — keep pending so `_onPhysicsUpdate` retries later + this._destroyNativeShape(); + this._clearMeshData(); + this._pendingNativeShapeCreation = true; } } else { this._destroyNativeShape(); this._clearMeshData(); + this._pendingNativeShapeCreation = false; } } } @@ -197,10 +213,13 @@ export class MeshColliderShape extends ColliderShape { ); if (!nativeShape) { + // Cook failed — `_onPhysicsUpdate` will retry next frame + this._pendingNativeShapeCreation = true; return; } this._nativeShape = nativeShape; + this._pendingNativeShapeCreation = false; // Sync base class properties (position, rotation, contactOffset, isTrigger, material) super._syncNative(); @@ -211,4 +230,38 @@ export class MeshColliderShape extends ColliderShape { this._attachToCollider(); } } + + /** + * @internal + * Retry hook: keep attempting `_createNativeShape` until it succeeds. + * + * Triggered every physics update tick by `Collider._onUpdate`. Handles the case + * where `_cookMesh` fails on first attempt due to PhysX cooking pipeline timing + * (the mesh data was extracted successfully at `set mesh` time, but the native + * cook call returned null). No-op once a valid native shape exists. + * + * We DO NOT re-call `_extractMeshData` here — once `set mesh` finished, either: + * - extraction succeeded → `_positions` is populated and we reuse it + * - extraction failed → `_clearMeshData` cleared `_positions`, and `mesh.accessible` + * won't recover (GPU upload is one-way), so re-extracting would fail again + */ + override _onPhysicsUpdate(): void { + if (!this._pendingNativeShapeCreation || !this._mesh || !this._positions) return; + this._createNativeShape(); + } + + /** + * @internal + * After CloneManager deep-copies `_positions` / `_indices` / `_mesh` and remaps `_collider`, + * the cloned shape still has no native PhysX shape because `_nativeShape` is `@ignoreClone`. + * Cook a fresh native shape now using the already-cloned vertex/index buffers; on transient + * cook failure `_createNativeShape` sets `_pendingNativeShapeCreation = true` and + * `_onPhysicsUpdate` will retry next tick. + */ + override _cloneTo(target: MeshColliderShape): void { + super._cloneTo(target); + if (target._positions) { + target._createNativeShape(); + } + } } diff --git a/packages/design/src/physics/IPhysics.ts b/packages/design/src/physics/IPhysics.ts index 17c9b6b466..91a6c1c7c6 100644 --- a/packages/design/src/physics/IPhysics.ts +++ b/packages/design/src/physics/IPhysics.ts @@ -36,6 +36,16 @@ export interface IPhysics { */ createPhysicsScene(physicsManager: IPhysicsManager): IPhysicsScene; + /** + * Get the default contact offset for collider shapes. + */ + getDefaultContactOffset?(): number; + + /** + * Get the default sleep threshold for dynamic colliders. + */ + getDefaultSleepThreshold?(): number; + /** * Create dynamic collider. * @param position - The global position diff --git a/packages/design/src/physics/IPhysicsScene.ts b/packages/design/src/physics/IPhysicsScene.ts index 5520114104..e5b8d9f335 100644 --- a/packages/design/src/physics/IPhysicsScene.ts +++ b/packages/design/src/physics/IPhysicsScene.ts @@ -43,6 +43,12 @@ export interface IPhysicsScene { */ update(elapsedTime: number): void; + /** + * Enable contact event buffering. + * @param enabled - Whether collision contact events should be buffered for dispatch. + */ + setContactEventEnabled(enabled: boolean): void; + /** * Collect buffered collision and trigger events. * Must be called after update() and after syncing transforms back from physics. diff --git a/packages/loader/src/AudioLoader.ts b/packages/loader/src/AudioLoader.ts index 46cc16e13e..29cd7d40b7 100644 --- a/packages/loader/src/AudioLoader.ts +++ b/packages/loader/src/AudioLoader.ts @@ -9,7 +9,7 @@ import { ResourceManager, resourceLoader } from "@galacean/engine-core"; -@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "m4a", "aac", "flac"]) +@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "audio", "m4a", "aac", "flac"]) class AudioLoader extends Loader { load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { return new AssetPromise((resolve, reject) => { diff --git a/packages/loader/src/gltf/extensions/GLTFExtensionSchema.ts b/packages/loader/src/gltf/extensions/GLTFExtensionSchema.ts index 522876e53b..126d3cab2b 100644 --- a/packages/loader/src/gltf/extensions/GLTFExtensionSchema.ts +++ b/packages/loader/src/gltf/extensions/GLTFExtensionSchema.ts @@ -1,5 +1,4 @@ import type { IMaterialNormalTextureInfo, ITextureInfo } from "../GLTFSchema"; -import type { RefItem } from "../../schema/CommonSchema"; /** * Interfaces from the KHR_lights_punctual extension @@ -153,7 +152,9 @@ export interface IEXTMeshoptCompressionSchema { filter?: "NONE" | "OCTAHEDRAL" | "QUATERNION" | "EXPONENTIAL"; } -export interface IGalaceanMaterialRemap extends RefItem { +export interface IGalaceanMaterialRemap { + url: string; + key?: string; isClone?: boolean; } diff --git a/packages/loader/src/gltf/parser/GLTFSceneParser.ts b/packages/loader/src/gltf/parser/GLTFSceneParser.ts index 5ee981b1d3..553bf177f2 100644 --- a/packages/loader/src/gltf/parser/GLTFSceneParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSceneParser.ts @@ -38,6 +38,7 @@ export class GLTFSceneParser extends GLTFParser { sceneRoot.addChild(context.get(GLTFParserType.Entity, sceneNodes[i])); } + (glTFResource._sceneRoots ||= [])[index] = sceneRoot; if (isDefaultScene) { glTFResource._defaultSceneRoot = sceneRoot; } @@ -195,49 +196,9 @@ export class GLTFSceneParser extends GLTFParser { if (rootBoneIndex !== -1) { BoundingBox.transform(mesh.bounds, inverseBindMatrices[rootBoneIndex], skinnedMeshRenderer.localBounds); } else { - // Root bone is not in joints list, we can only compute approximate inverse bind matrix - // Average all root bone's children inverse bind matrix - const approximateBindMatrix = new Matrix(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); - let subRootBoneCount = this._computeApproximateBindMatrix( - bones, - inverseBindMatrices, - rootBone, - approximateBindMatrix - ); - - if (subRootBoneCount !== 0) { - Matrix.multiplyScalar(approximateBindMatrix, 1.0 / subRootBoneCount, approximateBindMatrix); - BoundingBox.transform(mesh.bounds, approximateBindMatrix, skinnedMeshRenderer.localBounds); - } else { - skinnedMeshRenderer.localBounds.copyFrom(mesh.bounds); - } - } - } - - private _computeApproximateBindMatrix( - jointEntities: ReadonlyArray, - inverseBindMatrices: Matrix[], - rootEntity: Entity, - approximateBindMatrix: Matrix - ): number { - let subRootBoneCount = 0; - const children = rootEntity.children; - for (let i = 0, n = children.length; i < n; i++) { - const rootChild = children[i]; - const index = jointEntities.indexOf(rootChild); - if (index !== -1) { - Matrix.add(approximateBindMatrix, inverseBindMatrices[index], approximateBindMatrix); - subRootBoneCount++; - } else { - subRootBoneCount += this._computeApproximateBindMatrix( - jointEntities, - inverseBindMatrices, - rootChild, - approximateBindMatrix - ); - } + const inverseRootBoneWorld = new Matrix(); + Matrix.invert(rootBone.transform.worldMatrix, inverseRootBoneWorld); + BoundingBox.transform(mesh.bounds, inverseRootBoneWorld, skinnedMeshRenderer.localBounds); } - - return subRootBoneCount; } } diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.test.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.test.ts new file mode 100644 index 0000000000..bb5714b847 --- /dev/null +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +describe("GLTFSkinParser rootBone resolution", () => { + async function createParser(): Promise { + (globalThis as any).window = { AudioContext: undefined, TextMetrics: undefined }; + const { GLTFSkinParser } = await import("./GLTFSkinParser"); + return new GLTFSkinParser(); + } + + it("includes skinned mesh nodes when resolving missing skin.skeleton", async () => { + const parser = await createParser(); + const sceneRoot = { name: "GLTF_ROOT" }; + const meshRoot = { name: "Character_Man", parent: sceneRoot }; + const hips = { name: "mixamorig:Hips", parent: sceneRoot }; + const spine = { name: "mixamorig:Spine", parent: hips }; + + const rootBone = (parser as any)._findSkinRootBoneByLCA( + 0, + [1, 2], + [meshRoot, hips, spine], + [{ name: "Character_Man", skin: 0 }, { name: "mixamorig:Hips" }, { name: "mixamorig:Spine" }] + ); + + expect(rootBone).toBe(sceneRoot); + }); + + it("does not promote to the scene wrapper for unrelated top-level siblings", async () => { + const parser = await createParser(); + const sceneRoot = { name: "GLTF_ROOT" }; + const characterRoot = { name: "Character_Root", parent: sceneRoot }; + const mesh = { name: "Character_Mesh", parent: characterRoot }; + const hips = { name: "mixamorig:Hips", parent: characterRoot }; + const light = { name: "Light", parent: sceneRoot }; + + const rootBone = (parser as any)._findSkinRootBoneByLCA( + 0, + [3], + [characterRoot, mesh, light, hips], + [{ name: "Character_Root" }, { name: "Character_Mesh", skin: 0 }, { name: "Light" }, { name: "mixamorig:Hips" }] + ); + + expect(rootBone).toBe(characterRoot); + }); +}); diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index e8959b4b70..3f11f487e5 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -34,20 +34,17 @@ export class GLTFSkinParser extends GLTFParser { } skin.bones = bones; - // Get skeleton — when `skin.skeleton` is absent, resolve via joints' LCA - // LCA falls back to the GLTF_ROOT wrapper only when joints span multiple top-level scene nodes + // Get skeleton if (skeleton !== undefined) { const rootBone = entities[skeleton]; - if (!rootBone) { - throw `Skin skeleton index ${skeleton} is out of range.`; - } skin.rootBone = rootBone; } else { - const rootBone = this._findSkeletonRootBone(joints, entities); - if (!rootBone) { + const rootBone = this._findSkinRootBoneByLCA(index, joints, entities, glTF.nodes); + if (rootBone) { + skin.rootBone = rootBone; + } else { throw "Failed to find skeleton root bone."; } - skin.rootBone = rootBone; } return skin; @@ -56,32 +53,50 @@ export class GLTFSkinParser extends GLTFParser { return AssetPromise.resolve(skinPromise); } - /** - * Resolve the skeleton rootBone as the lowest common ancestor of the joints' parent chains. - * Returns null when joints share no common ancestor. - */ - private _findSkeletonRootBone(joints: number[], entities: Entity[]): Entity | null { - const paths = >{}; - for (const index of joints) { + private _findSkinRootBoneByLCA( + skinIndex: number, + joints: number[], + entities: Entity[], + nodes: Array<{ skin?: number }> = [] + ): Entity | null { + const nodeIndices = joints.slice(); + for (let i = 0, n = nodes.length; i < n; i++) { + if (nodes[i]?.skin === skinIndex) { + nodeIndices.push(i); + } + } + + return this._findRootBoneByLCA(nodeIndices, entities); + } + + private _findRootBoneByLCA(nodeIndices: number[], entities: Entity[]): Entity | null { + const paths: Entity[][] = []; + for (const index of nodeIndices) { const path = new Array(); let entity = entities[index]; while (entity) { path.unshift(entity); entity = entity.parent; } - paths[index] = path; + if (path.length) { + paths.push(path); + } + } + + if (!paths.length) { + return null; } let rootNode: Entity | null = null; for (let i = 0; ; i++) { - let path = paths[joints[0]]; + let path = paths[0]; if (i >= path.length) { return rootNode; } const entity = path[i]; - for (let j = 1, m = joints.length; j < m; j++) { - path = paths[joints[j]]; + for (let j = 1, m = paths.length; j < m; j++) { + path = paths[j]; if (i >= path.length || entity !== path[i]) { return rootNode; } diff --git a/packages/physics-lite/package.json b/packages/physics-lite/package.json index bc58a8decf..9eaa1e74c4 100644 --- a/packages/physics-lite/package.json +++ b/packages/physics-lite/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-physics-lite", - "version": "2.0.0-alpha.33", + "version": "0.0.0-experimental-2.0-game.14", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/physics-lite/src/LitePhysicsMaterial.ts b/packages/physics-lite/src/LitePhysicsMaterial.ts index 7983beb394..de1a0dee53 100644 --- a/packages/physics-lite/src/LitePhysicsMaterial.ts +++ b/packages/physics-lite/src/LitePhysicsMaterial.ts @@ -2,8 +2,16 @@ import { IPhysicsMaterial } from "@galacean/engine-design"; /** * Physics material describes how to handle colliding objects (friction, bounciness). + * + * Physics-lite does not implement material effects; setters are no-ops (matching + * the convention used by `LiteColliderShape.setMaterial/setContactOffset/setIsTrigger`). + * This lets engine-side state changes (including clone `_syncNative` re-writes) flow + * through without crashing while still leaving a one-time hint via console.log on + * the first write so users notice the limitation. */ export class LitePhysicsMaterial implements IPhysicsMaterial { + private static _warned = false; + constructor( staticFriction: number, dynamicFriction: number, @@ -16,39 +24,45 @@ export class LitePhysicsMaterial implements IPhysicsMaterial { * {@inheritDoc IPhysicsMaterial.setBounciness } */ setBounciness(value: number): void { - throw "Physics-lite don't support physics material. Use Physics-PhysX instead!"; + LitePhysicsMaterial._warnOnce(); } /** * {@inheritDoc IPhysicsMaterial.setDynamicFriction } */ setDynamicFriction(value: number): void { - throw "Physics-lite don't support physics material. Use Physics-PhysX instead!"; + LitePhysicsMaterial._warnOnce(); } /** * {@inheritDoc IPhysicsMaterial.setStaticFriction } */ setStaticFriction(value: number): void { - throw "Physics-lite don't support physics material. Use Physics-PhysX instead!"; + LitePhysicsMaterial._warnOnce(); } /** * {@inheritDoc IPhysicsMaterial.setBounceCombine } */ setBounceCombine(value: number): void { - throw "Physics-lite don't support physics material. Use Physics-PhysX instead!"; + LitePhysicsMaterial._warnOnce(); } /** * {@inheritDoc IPhysicsMaterial.setFrictionCombine } */ setFrictionCombine(value: number): void { - throw "Physics-lite don't support physics material. Use Physics-PhysX instead!"; + LitePhysicsMaterial._warnOnce(); } /** * {@inheritDoc IPhysicsMaterial.destroy } */ destroy(): void {} + + private static _warnOnce(): void { + if (LitePhysicsMaterial._warned) return; + LitePhysicsMaterial._warned = true; + console.log("Physics-lite don't support physics material. Use Physics-PhysX instead!"); + } } diff --git a/packages/physics-lite/src/LitePhysicsScene.ts b/packages/physics-lite/src/LitePhysicsScene.ts index 0b6b6eb3e8..58742b5069 100644 --- a/packages/physics-lite/src/LitePhysicsScene.ts +++ b/packages/physics-lite/src/LitePhysicsScene.ts @@ -115,6 +115,13 @@ export class LitePhysicsScene implements IPhysicsScene { } } + /** + * {@inheritDoc IPhysicsScene.setContactEventEnabled } + */ + setContactEventEnabled(_enabled: boolean): void { + // Physics-lite only produces trigger events, so there is no contact buffer to toggle. + } + /** * {@inheritDoc IPhysicsScene.updateEvents } */ diff --git a/packages/physics-physx/package.json b/packages/physics-physx/package.json index b545c794fc..d97107856f 100644 --- a/packages/physics-physx/package.json +++ b/packages/physics-physx/package.json @@ -1,6 +1,6 @@ { "name": "@galacean/engine-physics-physx", - "version": "2.0.0-alpha.33", + "version": "0.0.0-experimental-2.0-game.14", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/physics-physx/src/PhysXDynamicCollider.ts b/packages/physics-physx/src/PhysXDynamicCollider.ts index 31510ae2de..05614dd5bf 100644 --- a/packages/physics-physx/src/PhysXDynamicCollider.ts +++ b/packages/physics-physx/src/PhysXDynamicCollider.ts @@ -24,6 +24,20 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli private static _tempTranslation = new Vector3(); private static _tempRotation = new Quaternion(); + /** + * Whether actor is currently kinematic. + * PhysX 拒绝在 kinematic actor 上启用 CCD(会打印警告并忽略), + * 所以 setCollisionDetectionMode 在 kinematic 状态下只缓存目标值, + * 等切回 dynamic 时再真正写到 PhysX。 + */ + private _isKinematic: boolean = false; + + /** + * Cached collision detection mode. Always reflects user's intent. + * 实际 PhysX CCD flag 可能跟这个不一致(kinematic 时强制 Discrete)。 + */ + private _collisionDetectionMode: number = CollisionDetectionMode.Discrete; + constructor(physXPhysics: PhysXPhysics, position: Vector3, rotation: Quaternion) { super(physXPhysics); const transform = this._transform(position, rotation); @@ -158,10 +172,52 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli /** * {@inheritDoc IDynamicCollider.setCollisionDetectionMode } + * + * PhysX 在 kinematic actor 上调用 setRigidBodyFlag(eENABLE_CCD, true) 会触发警告: + * "kinematic bodies with CCD enabled are not supported! CCD will be ignored" + * 虽然 PhysX 会忽略这次调用而非真的拒绝(切回 dynamic 时 flag 不会自动恢复), + * 但每次 setIsKinematic 切换都会让这个 warning 重复打印,污染日志, + * 同时让 actor 在 dynamic 状态下 CCD flag 状态不确定。 + * + * 解决: 只在 dynamic 状态时立即 apply CCD flags。kinematic 时仅缓存到 + * `_collisionDetectionMode`,等切回 dynamic 时由 setIsKinematic 重新 apply。 */ setCollisionDetectionMode(value: number): void { + this._collisionDetectionMode = value; + if (!this._isKinematic) { + this._applyCollisionDetectionFlags(value); + } + } + + /** + * {@inheritDoc IDynamicCollider.setUseGravity } + */ + setUseGravity(value: boolean): void { + this._pxActor.setActorFlag(this._physXPhysics._physX.PxActorFlag.eDISABLE_GRAVITY, !value); + } + + /** + * {@inheritDoc IDynamicCollider.setIsKinematic } + * + * 切换 kinematic 状态时同步处理 CCD flag: + * - 切到 kinematic 前先关 CCD(避免 PhysX 警告 + 让状态显式) + * - 切回 dynamic 后恢复用户期望的 CCD mode(来自 `_collisionDetectionMode` 缓存) + */ + setIsKinematic(value: boolean): void { + if (this._isKinematic === value) return; const physX = this._physXPhysics._physX; + if (value) { + this._applyCollisionDetectionFlags(CollisionDetectionMode.Discrete); + this._pxActor.setRigidBodyFlag(physX.PxRigidBodyFlag.eKINEMATIC, true); + } else { + this._pxActor.setRigidBodyFlag(physX.PxRigidBodyFlag.eKINEMATIC, false); + this._applyCollisionDetectionFlags(this._collisionDetectionMode); + } + this._isKinematic = value; + } + private _applyCollisionDetectionFlags(value: number): void { + const physX = this._physXPhysics._physX; switch (value) { case CollisionDetectionMode.Continuous: this._pxActor.setRigidBodyFlag(physX.PxRigidBodyFlag.eENABLE_CCD, true); @@ -186,24 +242,6 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli } } - /** - * {@inheritDoc IDynamicCollider.setUseGravity } - */ - setUseGravity(value: boolean): void { - this._pxActor.setActorFlag(this._physXPhysics._physX.PxActorFlag.eDISABLE_GRAVITY, !value); - } - - /** - * {@inheritDoc IDynamicCollider.setIsKinematic } - */ - setIsKinematic(value: boolean): void { - if (value) { - this._pxActor.setRigidBodyFlag(this._physXPhysics._physX.PxRigidBodyFlag.eKINEMATIC, true); - } else { - this._pxActor.setRigidBodyFlag(this._physXPhysics._physX.PxRigidBodyFlag.eKINEMATIC, false); - } - } - /** * {@inheritDoc IDynamicCollider.setConstraints } */ @@ -213,34 +251,52 @@ export class PhysXDynamicCollider extends PhysXCollider implements IDynamicColli /** * {@inheritDoc IDynamicCollider.addForce } + * + * PhysX 在 kinematic actor 上调 addForce 是 no-op(doc: "kinematic bodies don't + * respond to forces")。提前 return 避免无意义的 wasm boundary cross。 + * + * Sleeping actor 不需要显式 wakeUp — wasm binding 调用 `addForce(force, eFORCE, + * autowake=true)`,PhysX 自动唤醒(已通过 `applyForce on sleeping actor` 测试验证)。 */ addForce(force: Vector3) { + if (this._isKinematic) return; this._pxActor.addForce({ x: force.x, y: force.y, z: force.z }); } /** * {@inheritDoc IDynamicCollider.addTorque } + * + * 同 addForce — kinematic 提前 return,sleeping 由 PhysX autowake 自动处理。 */ addTorque(torque: Vector3) { + if (this._isKinematic) return; this._pxActor.addTorque({ x: torque.x, y: torque.y, z: torque.z }); } /** * {@inheritDoc IDynamicCollider.move } + * + * PhysX 要求 setKinematicTarget 的 rotation 是 normalized quaternion,否则会触发 + * 内部 assertion / 警告,并把 actor 转到错误的姿态。所以在写入 wasm 边界前统一 normalize。 */ move(positionOrRotation: Vector3 | Quaternion, rotation?: Quaternion): void { + const tempTranslation = PhysXDynamicCollider._tempTranslation; + const tempRotation = PhysXDynamicCollider._tempRotation; + if (rotation) { - this._pxActor.setKinematicTarget(positionOrRotation, rotation); + tempRotation.copyFrom(rotation).normalize(); + this._pxActor.setKinematicTarget(positionOrRotation, tempRotation); return; } - const tempTranslation = PhysXDynamicCollider._tempTranslation; - const tempRotation = PhysXDynamicCollider._tempRotation; - this.getWorldTransform(tempTranslation, tempRotation); if (positionOrRotation instanceof Vector3) { + this.getWorldTransform(tempTranslation, tempRotation); + // current rotation read from PhysX is already normalized; no extra work needed this._pxActor.setKinematicTarget(positionOrRotation, tempRotation); } else { - this._pxActor.setKinematicTarget(tempTranslation, positionOrRotation); + this.getWorldTransform(tempTranslation, tempRotation); + tempRotation.copyFrom(positionOrRotation).normalize(); + this._pxActor.setKinematicTarget(tempTranslation, tempRotation); } } diff --git a/packages/physics-physx/src/PhysXPhysics.ts b/packages/physics-physx/src/PhysXPhysics.ts index 6c5d788aa1..867ee68dff 100644 --- a/packages/physics-physx/src/PhysXPhysics.ts +++ b/packages/physics-physx/src/PhysXPhysics.ts @@ -57,13 +57,26 @@ export class PhysXPhysics implements IPhysics { private _tolerancesScale: any; private _wasmSIMDModeUrl: string; private _wasmModeUrl: string; + private _tolerancesScaleOptions: PhysXTolerancesScale | undefined; + private _defaultContactOffset = 0.02; + private _defaultSleepThreshold = 5e-3; /** * Create a PhysXPhysics instance. * @param runtimeMode - Runtime mode, `Auto` prefers WebAssembly SIMD if supported @see {@link PhysXRuntimeMode} * @param runtimeUrls - Manually specify the runtime URLs + * @param options - PhysX options. */ - constructor(runtimeMode: PhysXRuntimeMode = PhysXRuntimeMode.Auto, runtimeUrls?: PhysXRuntimeUrls) { + constructor(runtimeMode?: PhysXRuntimeMode, runtimeUrls?: PhysXRuntimeUrls, options?: PhysXPhysicsOptions); + constructor(options?: PhysXPhysicsOptions); + constructor( + runtimeModeOrOptions: PhysXRuntimeMode | PhysXPhysicsOptions = PhysXRuntimeMode.Auto, + runtimeUrls?: PhysXRuntimeUrls, + options?: PhysXPhysicsOptions + ) { + const isOptionsObject = typeof runtimeModeOrOptions === "object"; + const runtimeMode = isOptionsObject ? PhysXRuntimeMode.Auto : (runtimeModeOrOptions ?? PhysXRuntimeMode.Auto); + const resolvedOptions = isOptionsObject ? runtimeModeOrOptions : options; this._runTimeMode = runtimeMode; this._wasmSIMDModeUrl = runtimeUrls?.wasmSIMDModeUrl ?? @@ -71,6 +84,8 @@ export class PhysXPhysics implements IPhysics { this._wasmModeUrl = runtimeUrls?.wasmModeUrl ?? "https://mdn.alipayobjects.com/rms/afts/file/A*DFuvR6Mv5C0AAAAAQ4AAAAgAehQnAQ/physx.release.js"; + this._tolerancesScaleOptions = resolvedOptions?.tolerancesScale; + this._updateScaledDefaults(this._tolerancesScaleOptions?.length ?? 1, this._tolerancesScaleOptions?.speed ?? 10); } /** @@ -156,6 +171,20 @@ export class PhysXPhysics implements IPhysics { return scene; } + /** + * {@inheritDoc IPhysics.getDefaultContactOffset } + */ + getDefaultContactOffset(): number { + return this._defaultContactOffset; + } + + /** + * {@inheritDoc IPhysics.getDefaultSleepThreshold } + */ + getDefaultSleepThreshold(): number { + return this._defaultSleepThreshold; + } + /** * {@inheritDoc IPhysics.createStaticCollider } */ @@ -279,6 +308,7 @@ export class PhysXPhysics implements IPhysics { const allocator = new physX.PxDefaultAllocator(); const pxFoundation = physX.PxCreateFoundation(version, allocator, defaultErrorCallback); const tolerancesScale = new physX.PxTolerancesScale(); + this._applyTolerancesScale(tolerancesScale); const pxPhysics = physX.PxCreatePhysics(version, pxFoundation, tolerancesScale, false, null); physX.PxInitExtensions(pxPhysics, null); @@ -302,6 +332,29 @@ export class PhysXPhysics implements IPhysics { this._allocator = allocator; this._tolerancesScale = tolerancesScale; } + + private _applyTolerancesScale(tolerancesScale: any): void { + const length = this._tolerancesScaleOptions?.length ?? tolerancesScale.length; + const speed = this._tolerancesScaleOptions?.speed ?? tolerancesScale.speed; + + this._assertPositiveFinite(length, "tolerancesScale.length"); + this._assertPositiveFinite(speed, "tolerancesScale.speed"); + + tolerancesScale.length = length; + tolerancesScale.speed = speed; + this._updateScaledDefaults(length, speed); + } + + private _assertPositiveFinite(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`PhysXPhysics ${name} must be a positive finite number.`); + } + } + + private _updateScaledDefaults(length: number, speed: number): void { + this._defaultContactOffset = 0.02 * length; + this._defaultSleepThreshold = 5e-5 * speed * speed; + } } enum InitializeState { @@ -316,3 +369,15 @@ interface PhysXRuntimeUrls { /*** The URL of `PhysXRuntimeMode.WebAssemblySIMD` mode. */ wasmSIMDModeUrl?: string; } + +export interface PhysXTolerancesScale { + /** Approximate object length in the simulation unit. PhysX default is 1. */ + length?: number; + /** Typical object speed in the simulation unit. PhysX default is 10. */ + speed?: number; +} + +export interface PhysXPhysicsOptions { + /** PhysX world unit scale used before PxPhysics, PxSceneDesc and PxCookingParams are created. */ + tolerancesScale?: PhysXTolerancesScale; +} diff --git a/packages/physics-physx/src/PhysXPhysicsScene.ts b/packages/physics-physx/src/PhysXPhysicsScene.ts index d026304e4d..bee2ab2dc2 100644 --- a/packages/physics-physx/src/PhysXPhysicsScene.ts +++ b/packages/physics-physx/src/PhysXPhysicsScene.ts @@ -50,6 +50,7 @@ export class PhysXPhysicsScene implements IPhysicsScene { private _activeTriggers: DisorderedArray = new DisorderedArray(); private _contactEvents: ContactEvent[] = []; private _contactEventCount = 0; + private _contactEventEnabled = true; private _triggerEvents: TriggerEvent[] = []; private _physicsEvents: IPhysicsEvents = { contactEvents: [], contactEventCount: 0, triggerEvents: [] }; @@ -191,6 +192,20 @@ export class PhysXPhysicsScene implements IPhysicsScene { this._fetchResults(); } + /** + * {@inheritDoc IPhysicsScene.setContactEventEnabled } + */ + setContactEventEnabled(enabled: boolean): void { + if (this._contactEventEnabled === enabled) { + return; + } + + this._contactEventEnabled = enabled; + if (!enabled) { + this._contactEventCount = 0; + } + } + /** * {@inheritDoc IPhysicsScene.updateEvents } */ @@ -547,6 +562,8 @@ export class PhysXPhysicsScene implements IPhysicsScene { } private _bufferContactEvent(collision: ICollision, state: number): void { + if (!this._contactEventEnabled) return; + const index = this._contactEventCount++; const event = (this._contactEvents[index] ||= new ContactEvent()); event.shape0Id = collision.shape0Id; diff --git a/packages/physics-physx/src/shape/PhysXColliderShape.ts b/packages/physics-physx/src/shape/PhysXColliderShape.ts index ea5a3df8b4..61b9fdf6a3 100644 --- a/packages/physics-physx/src/shape/PhysXColliderShape.ts +++ b/packages/physics-physx/src/shape/PhysXColliderShape.ts @@ -56,6 +56,7 @@ export abstract class PhysXColliderShape implements IColliderShape { constructor(physXPhysics: PhysXPhysics) { this._physXPhysics = physXPhysics; + this._contractOffset = physXPhysics.getDefaultContactOffset(); } /** diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 9544a7591e..22fe8b110b 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -20,8 +20,8 @@ import { } from "@galacean/engine-core"; import "@galacean/engine-loader"; import type { GLTFResource } from "@galacean/engine-loader"; -import { Quaternion, Vector3 } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine"; +import { Quaternion } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { glbResource } from "./model/fox"; const canvasDOM = document.createElement("canvas"); @@ -70,18 +70,16 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); stateMachine.clearEntryStateTransitions(); - // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorState) + // 清理各状态的 transitions 并恢复默认属性 const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { - const view = animator.findAnimatorState(name); - if (view) { - const def = (view as any)._state; - def.clearTransitions(); - def.speed = 1; - def.clipStartTime = 0; - def.clipEndTime = 1; - def.wrapMode = WrapMode.Loop; - def.clip?.clearEvents(); + const state = animator.findAnimatorState(name); + if (state) { + state.clearTransitions(); + state.speed = 1; + state.clipStartTime = 0; + state.clipEndTime = 1; + state.wrapMode = WrapMode.Loop; } } }); @@ -198,9 +196,9 @@ describe("Animator test", function () { it("find animator state", () => { const stateName = "Survey"; const expectedStateName = "Run"; + const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; animator.play(stateName); - const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); expect(animatorState).to.eq(currentAnimatorState); @@ -208,7 +206,7 @@ describe("Animator test", function () { animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); expect(animatorState).not.to.eq(currentAnimatorState); - expect(animatorState?.name).to.eq(expectedStateName); + expect(animatorState.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { @@ -240,6 +238,89 @@ describe("Animator test", function () { expect(layerState).to.eq(2); }); + it("crossFade advances with per-instance playData speed instead of shared AnimatorState speed", () => { + const sharedStates = animator.animatorController.layers[0].stateMachine.states; + const sharedWalkState = sharedStates.find((state) => state.name === "Walk"); + const sharedRunState = sharedStates.find((state) => state.name === "Run"); + const oldWalkSpeed = sharedWalkState.speed; + const oldRunSpeed = sharedRunState.speed; + + try { + animator.play("Walk"); + animator.crossFade("Run", 1.0, 0); + + const layerData = animator["_animatorLayersData"][0]; + layerData.srcPlayData.speed = 0.25; + layerData.destPlayData.speed = 0.25; + sharedWalkState.speed = 10; + sharedRunState.speed = 10; + + const srcPlayedTime = layerData.srcPlayData.playedTime; + const destPlayedTime = layerData.destPlayData.playedTime; + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.2); + + expect(layerData.srcPlayData.playedTime - srcPlayedTime).toBeCloseTo(0.05, 5); + expect(layerData.destPlayData.playedTime - destPlayedTime).toBeCloseTo(0.05, 5); + } finally { + sharedWalkState.speed = oldWalkSpeed; + sharedRunState.speed = oldRunSpeed; + } + }); + + it("playData wrapMode overrides shared AnimatorState wrapMode per instance", () => { + const sharedWalkState = animator.animatorController.layers[0].stateMachine.states.find( + (state) => state.name === "Walk" + ); + const oldWrapMode = sharedWalkState.wrapMode; + + try { + sharedWalkState.wrapMode = WrapMode.Loop; + animator.play("Walk"); + + const layerData = animator["_animatorLayersData"][0]; + const playData = layerData.srcPlayData; + playData.wrapMode = WrapMode.Once; + + expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); + + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(playData.state.clip.length + 0.1); + + expect(layerData.layerState).to.eq(LayerState.Finished); + } finally { + sharedWalkState.wrapMode = oldWrapMode; + } + }); + + it("playData wrapMode does not leak between animators sharing one controller", () => { + const sharedWalkState = animator.animatorController.layers[0].stateMachine.states.find( + (state) => state.name === "Walk" + ); + const oldWrapMode = sharedWalkState.wrapMode; + const otherEntity = new Entity(engine); + const otherAnimator = otherEntity.addComponent(Animator); + otherAnimator.animatorController = animator.animatorController; + + try { + sharedWalkState.wrapMode = WrapMode.Loop; + animator.play("Walk"); + otherAnimator.play("Walk"); + + const playData = animator["_animatorLayersData"][0].srcPlayData; + const otherPlayData = otherAnimator["_animatorLayersData"][0].srcPlayData; + playData.wrapMode = WrapMode.Once; + + expect(otherPlayData.wrapMode).to.eq(WrapMode.Loop); + expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); + } finally { + sharedWalkState.wrapMode = oldWrapMode; + otherEntity.destroy(); + } + }); + it("cross fade in fixed time", () => { const runState = animator.findAnimatorState("Run"); animator.play("Walk"); @@ -252,25 +333,25 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const srcPlayData = layerData.srcPlayData; - expect(srcPlayData.instance.name).to.eq("Run"); + expect(srcPlayData.state.name).to.eq("Run"); expect(srcPlayData.playedTime).to.eq(0.3); // @ts-ignore - expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * (runState as any)._state._getDuration()); + expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * runState._getDuration()); }); it("animation cross fade by transition", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = (runState as any)._state; + transition.destinationState = runState; transition.duration = 1; transition.exitTime = 1; - (walkState as any)._state.addTransition(transition); + walkState.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length - 0.1); + animator.update(walkState.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -313,7 +394,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = (animator.findAnimatorState("Run") as any)._state.clip; + const clip = animator.findAnimatorState("Run").clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -368,64 +449,82 @@ describe("Animator test", function () { expect(testScriptSpy).toHaveBeenCalledTimes(1); }); - it("eventHandlers rebuild when Script is added after play (no clip event mutation)", () => { - const event = new AnimationEvent(); - event.functionName = "event0"; - event.time = 0; - const state = animator.findAnimatorState("Walk")!; - state.clip.addEvent(event); // event exists before play - - animator.play("Walk"); // script not yet attached; first build sees zero scripts + it("fireEvents gates AnimationEvent dispatch without consuming the event", () => { + animator.play("Walk"); class TestScript extends Script { event0(): void {} } - const script = animator.entity.addComponent(TestScript); // does NOT bump clip _version - const spy = vi.spyOn(script, "event0"); - // @ts-ignore - animator.engine.time._frameCount++; + const testScript = animator.entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + + const state = animator.findAnimatorState("Walk"); + state.clip.addEvent(event0); + + animator.fireEvents = false; + animator.update(0); + expect(testScriptSpy).not.toHaveBeenCalled(); + + animator.fireEvents = true; animator.update(0.1); - expect(spy).toHaveBeenCalledTimes(1); + expect(testScriptSpy).toHaveBeenCalledTimes(1); }); - it("eventHandlers rebuild when state.clip is swapped (state version covers clip swap)", () => { - const walkState = animator.animatorController.layers[0].stateMachine.findStateByName("Walk"); - const runState = animator.animatorController.layers[0].stateMachine.findStateByName("Run"); - const originalClip = walkState.clip; + it("does not refire animation events when a once clip reaches the end", () => { + const entity = new Entity(engine); + const onceAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("Base Layer"); + controller.addLayer(layer); - // Old clip: add an event matching event0 - const oldEvent = new AnimationEvent(); - oldEvent.functionName = "event0"; - oldEvent.time = 0; - originalClip.addEvent(oldEvent); + const state = layer.stateMachine.addState("once"); + state.wrapMode = WrapMode.Once; + + const clip = new AnimationClip("once-clip"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 1; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); class TestScript extends Script { event0(): void {} } - const script = animator.entity.addComponent(TestScript); - const spy0 = vi.spyOn(script, "event0"); - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - expect(spy0).toHaveBeenCalledTimes(1); + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.5; + clip.addEvent(event0); + state.clip = clip; + onceAnimator.animatorController = controller; - // Swap to a different real clip (Run's) that has no event0; even if the new clip's - // _version happens to match the prior snapshot, the swap itself must invalidate the - // cached eventHandlers via state._updateFlagManager dispatch - walkState.clip = runState.clip; + const testScript = entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); - spy0.mockClear(); - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - expect(spy0).toHaveBeenCalledTimes(0); // swapped clip has no event0 + try { + onceAnimator.play("once"); + // @ts-ignore + onceAnimator.engine.time._frameCount++; + onceAnimator.update(0.75); + expect(testScriptSpy).toHaveBeenCalledTimes(1); - // restore for other tests - walkState.clip = originalClip; + // @ts-ignore + onceAnimator.engine.time._frameCount++; + onceAnimator.update(0.5); + expect(testScriptSpy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } }); it("stateMachine", () => { @@ -434,11 +533,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._state.clearTransitions(); + idleState.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); + runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -446,68 +545,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._state; + toWalkTransition.destinationState = walkState; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._state.addTransition(toWalkTransition); + idleState.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * (idleState as any)._state._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * idleState._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._state._getDuration(); + toWalkTransition.duration * walkState._getDuration(); - const exitTransition = (idleState as any)._state.addExitTransition(); + const exitTransition = idleState.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._state; + toRunTransition.destinationState = runState; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._state.addTransition(toRunTransition); + walkState.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._state._getDuration(); + toRunTransition.duration * runState._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._state; + toIdleTransition.destinationState = idleState; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._state.addTransition(toIdleTransition); + walkState.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._state; + runToWalkTransition.destinationState = walkState; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._state.addTransition(runToWalkTransition); + runState.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._state._getDuration(); + runToWalkTransition.duration * walkState._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._state); + stateMachine.addEntryStateTransition(idleState); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyTransition = stateMachine.addAnyStateTransition(idleState); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; anyTransition.hasExitTime = true; anyTransition.exitTime = 0.7; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._state._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -559,11 +658,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._state.clearTransitions(); + idleState.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); + runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -571,68 +670,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._state; + toWalkTransition.destinationState = walkState; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._state.addTransition(toWalkTransition); + idleState.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * (idleState as any)._state._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * idleState._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._state._getDuration(); + toWalkTransition.duration * walkState._getDuration(); - const exitTransition = (idleState as any)._state.addExitTransition(); + const exitTransition = idleState.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._state; + toRunTransition.destinationState = runState; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._state.addTransition(toRunTransition); + walkState.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._state._getDuration(); + toRunTransition.duration * runState._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._state; + toIdleTransition.destinationState = idleState; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._state.addTransition(toIdleTransition); + walkState.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._state; + runToWalkTransition.destinationState = walkState; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._state.addTransition(runToWalkTransition); + runState.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._state._getDuration(); + runToWalkTransition.duration * walkState._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._state); + stateMachine.addEntryStateTransition(idleState); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyTransition = stateMachine.addAnyStateTransition(idleState); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; anyTransition.hasExitTime = true; anyTransition.exitTime = 0.3; let anyToIdleTime = // @ts-ignore - (1 - anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._state._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -676,10 +775,10 @@ describe("Animator test", function () { it("transitionOffset", () => { const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clearTransitions(); + const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -689,7 +788,7 @@ describe("Animator test", function () { animator.update(0.01); const destPlayData = animator["_animatorLayersData"][0].destPlayData; - const destState = (destPlayData.instance as any)._state; + const destState = destPlayData.state; const transitionDuration = toRunTransition.duration * destState._getDuration(); const crossWeight = animator["_animatorLayersData"][0].destPlayData.playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); @@ -698,21 +797,21 @@ describe("Animator test", function () { it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); walkState.wrapMode = WrapMode.Once; - (walkState as any)._state.clipStartTime = 0.8; - (walkState as any)._state.clearTransitions(); + walkState.clipStartTime = 0.8; + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clearTransitions(); + const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - (runState as any)._state.clipStartTime = 0.5; + runState.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); const destPlayData = animator["_animatorLayersData"][0].destPlayData; - expect(destPlayData.instance?.name).to.eq("Run"); + expect(destPlayData.state?.name).to.eq("Run"); }); it("transition to exit but no entry", () => { @@ -720,8 +819,8 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); walkState.wrapMode = WrapMode.Once; - (walkState as any)._state.clearTransitions(); - (walkState as any)._state.addExitTransition(); + walkState.clearTransitions(); + walkState.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -888,15 +987,15 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - (walkState as any)._state.clipStartTime = 0.5; - (walkState as any)._state.addStateMachineScript( + walkState.clipStartTime = 0.5; + walkState.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition((animator.findAnimatorState("Run") as any)._state); + const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run")); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -907,9 +1006,9 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.5); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.5); - expect(layerData.srcPlayData.clipTime).to.eq((walkState as any)._state.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData.clipTime).to.eq(walkState.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -922,13 +1021,13 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const idleState = animator.findAnimatorState("Survey"); idleState.speed = 1; - (idleState as any)._state.clearTransitions(); + idleState.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clipStartTime = 0; - (walkState as any)._state.clearTransitions(); + walkState.clipStartTime = 0; + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -936,10 +1035,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.5); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + animator.update(walkState.clip.length * 0.5); + expect(layerData.destPlayData.state.name).to.eq("Run"); expect(layerData.destPlayData.playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -947,13 +1046,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update((idleState as any)._state.clip.length * 0.2 - 0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Survey"); - expect(layerData.srcPlayData.clipTime).to.eq((idleState as any)._state.clip.length * 0.2); + animator.update(idleState.clip.length * 0.2 - 0.1); + expect(layerData.srcPlayData.state.name).to.eq("Survey"); + expect(layerData.srcPlayData.clipTime).to.eq(idleState.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -966,16 +1065,16 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clipStartTime = 0; - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clipStartTime = 0; + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = (runState as any)._state.addTransition((walkState as any)._state); + const runToWalkTransition = runState.addTransition(walkState); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -987,28 +1086,28 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); expect(layerData.destPlayData.playedTime).to.eq(0.1); expect(animator.getParameterValue("triggerRun")).to.eq(false); expect(animator.getParameterValue("triggerWalk")).to.eq(true); // @ts-ignore animator.engine.time._frameCount++; - animator.update((runState as any)._state.clip.length * 0.1 - 0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq((runState as any)._state.clip.length * 0.1); + animator.update(runState.clip.length * 0.1 - 0.1); + expect(layerData.srcPlayData.state.name).to.eq("Run"); + expect(layerData.srcPlayData.playedTime).to.eq(runState.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update((runState as any)._state.clip.length * 0.6); - expect(layerData.destPlayData.instance.name).to.eq("Walk"); + animator.update(runState.clip.length * 0.6); + expect(layerData.destPlayData.state.name).to.eq("Walk"); expect(layerData.destPlayData.playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.3); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq((walkState as any)._state.clip.length * 0.3); + animator.update(walkState.clip.length * 0.3); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); + expect(layerData.srcPlayData.playedTime).to.eq(walkState.clip.length * 0.3); }); it("fixedDuration", () => { @@ -1018,11 +1117,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clipStartTime = (runState as any)._state.clipEndTime = 0; - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + runState.clipStartTime = runState.clipEndTime = 0; + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -1032,7 +1131,7 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); expect(layerData.srcPlayData.clipTime).to.eq(0); }); @@ -1095,13 +1194,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.6); - expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state1"); + expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state1"); transition2.mute = false; // @ts-ignore animator.engine.time._frameCount++; animator.update(0.3); - expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state2"); + expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state2"); }); it("Clone", () => { @@ -1150,6 +1249,40 @@ describe("Animator test", function () { expect(spine.transform.position.y).to.eq(1); }); + it("sampleAnimation samples clip curves without firing AnimationEvents", () => { + const entity = new Entity(engine, "sample-root"); + const clip = new AnimationClip("sample"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 3; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + + class TestScript extends Script { + event0(): void {} + } + + const script = entity.addComponent(TestScript); + const eventSpy = vi.spyOn(script, "event0"); + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + clip.addEvent(event0); + + try { + clip.sampleAnimation(entity, 1); + expect(entity.transform.position.x).to.eq(3); + expect(eventSpy).not.toHaveBeenCalled(); + } finally { + entity.destroy(); + } + }); + it("anyState transition interrupts crossFade", () => { const { animatorController } = animator; animatorController.addParameter("interrupt", false); @@ -1157,7 +1290,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1174,7 +1307,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Trigger interrupt during crossFade animator.setParameterValue("interrupt", true); @@ -1183,7 +1316,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.state.name).to.eq("Survey"); }); it("noExitTime transition scan should ignore exitTime transitions", () => { @@ -1195,12 +1328,12 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - (walkState as any)._state.clipStartTime = 0; - (walkState as any)._state.clipEndTime = 1; - (walkState as any)._state.clearTransitions(); + walkState.clipStartTime = 0; + walkState.clipEndTime = 1; + walkState.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = (walkState as any)._state.addTransition((idleState as any)._state); + const noExitFailTransition = walkState.addTransition(idleState); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1209,26 +1342,26 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = (runState as any)._state; + exitTimeTransition.destinationState = runState; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - (walkState as any)._state.addTransition(exitTimeTransition); + walkState.addTransition(exitTimeTransition); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); animator.play("Walk"); // Update before exitTime, should still be in Walk and not start transitioning to Run. - const preExitDeltaTime = (walkState as any)._state.clip.length * 0.25; + const preExitDeltaTime = walkState.clip.length * 0.25; // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); - expect(layerData.destPlayData).to.be.null; + expect(layerData.srcPlayData.state.name).to.eq("Walk"); + expect(layerData.destPlayData.state).to.be.undefined; // Update past exitTime, should transition to Run. // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.5); + animator.update(walkState.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1240,7 +1373,7 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1250,7 +1383,7 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length + 0.1); + animator.update(walkState.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1264,7 +1397,7 @@ describe("Animator test", function () { animator.update(0.1); expect(layerData.layerState).to.eq(LayerState.FixedCrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Trigger interrupt during FixedCrossFading animator.setParameterValue("interrupt", true); @@ -1273,7 +1406,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.state.name).to.eq("Survey"); }); it("anyState interrupt should skip transition to same destination state", () => { @@ -1283,7 +1416,7 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition((runState as any)._state); + const anyToRun = stateMachine.addAnyStateTransition(runState); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1300,7 +1433,7 @@ describe("Animator test", function () { // Should be in CrossFading state, dest = Run expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Update again - anyState -> Run should be skipped because dest is already Run // @ts-ignore @@ -1309,7 +1442,7 @@ describe("Animator test", function () { // Should still be CrossFading to Run (not interrupted/reset) expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); }); it("zero-duration crossFade should not be interrupted by anyState transition", () => { @@ -1319,7 +1452,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1335,26 +1468,26 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); // Zero-duration crossFade completes instantly, should be Playing Run (not interrupted to Survey) - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); }); it("toggle hasExitTime should maintain correct noExitTimeCount", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - (walkState as any)._state.clearTransitions(); + walkState.clearTransitions(); // Add a noExitTime transition - const t1 = (walkState as any)._state.addTransition((runState as any)._state); + const t1 = walkState.addTransition(runState); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = (walkState as any)._state.addTransition((idleState as any)._state); + const t2 = walkState.addTransition(idleState); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = (walkState as any)._state._transitionCollection; + const collection = walkState._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1373,446 +1506,4 @@ describe("Animator test", function () { expect(collection.get(0)).to.eq(t2); expect(collection.get(1)).to.eq(t1); }); - - it("findAnimatorState lazy-creates handle for unplayed state", () => { - // Clone yields a fresh animator with no PlayData populated - const cloneEntity = animator.entity.clone(); - const cloneAnimator = cloneEntity.getComponent(Animator); - - const survey = cloneAnimator.findAnimatorState("Survey"); - expect(survey).to.not.eq(null); - expect(survey.name).to.eq("Survey"); - expect(survey.speed).to.eq((survey as any)._state.speed); // live-bound default - - // Same handle returned on subsequent calls (verifies caching) - expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); - }); - - it("per-instance speed set before play applies on first play", () => { - const handle = animator.findAnimatorState("Survey"); - handle.speed = 0.5; - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.001); - - // Same handle observed via getCurrentAnimatorState - expect(animator.getCurrentAnimatorState(0)).to.eq(handle); - expect(handle.speed).to.eq(0.5); - }); - - it("per-instance speed survives crossFade out and back", () => { - animator.findAnimatorState("Survey").speed = 0.5; - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.001); - - // crossFade out (fixed 0.05s duration) - animator.crossFadeInFixedDuration("Walk", 0.05, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); // complete crossfade - // crossFade back - animator.crossFadeInFixedDuration("Survey", 0.05, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const srcPlayData = animator._animatorLayersData[0].srcPlayData; - expect(srcPlayData.instance.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey - expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); - expect(srcPlayData.instance.speed).to.eq(0.5); - }); - - it("per-instance speed is per-Animator (clone isolation)", () => { - const cloneEntity = animator.entity.clone(); - const cloneAnimator = cloneEntity.getComponent(Animator); - expect(cloneAnimator.animatorController).to.eq(animator.animatorController); - - animator.findAnimatorState("Survey").speed = 0.5; - - expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); - expect(cloneAnimator.findAnimatorState("Survey").speed).to.eq(1); // shared default - // shared asset not mutated - const sharedSurvey = animator.animatorController.layers[0].stateMachine.findStateByName("Survey"); - expect(sharedSurvey.speed).to.eq(1); - }); - - it("crossFade phase honors per-instance speed for time progression", () => { - // Set high per-instance speed on src state - animator.findAnimatorState("Survey").speed = 4; - animator.play("Survey"); - // @ts-ignore — Animator.update short-circuits to dt=0 if _playFrameCount===frameCount - animator.engine.time._frameCount++; - animator.update(0.001); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const srcPlayedBefore = layerData.srcPlayData.playedTime; - - // Start crossFade — during crossFade, src should still advance per per-instance speed=4 - animator.crossFade("Walk", 0.5, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); // 50ms of crossfade - - const srcPlayedAfter = layerData.srcPlayData.playedTime; - const advanced = srcPlayedAfter - srcPlayedBefore; - // With per-instance speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With shared state.speed=1 it'd be ~0.05. - expect(advanced).to.be.closeTo(0.2, 0.05); - }); - - it("findAnimatorState rebuilds handle when state identity changes (remove/re-add same name)", () => { - const sm = animator.animatorController.layers[0].stateMachine; - const oldSurvey = animator.findAnimatorState("Survey"); - expect(oldSurvey).not.to.eq(null); - const oldStateRef = (oldSurvey as any)._state; - const originalIndex = sm.states.indexOf(oldStateRef); - - // Simulate dynamic controller mutation: remove and re-add same-name state - sm.removeState(oldStateRef); - const newStateRef = sm.addState("Survey"); - expect(newStateRef).not.to.eq(oldStateRef); - - const newHandle = animator.findAnimatorState("Survey"); - expect(newHandle).not.to.eq(null); - expect((newHandle as any)._state).to.eq(newStateRef); - expect(newHandle).not.to.eq(oldSurvey); - - // Restore original Survey state so subsequent tests still see the - // clip-bound state. Drop the barebones replacement and reinsert the - // original at its previous index in the states list/map. - sm.removeState(newStateRef); - sm.states.splice(originalIndex, 0, oldStateRef); - // @ts-ignore — _statesMap is private but rebuild requires direct access - sm._statesMap["Survey"] = oldStateRef; - // Reset cached layer data so the next findAnimatorState rebuilds against - // the restored state. - // @ts-ignore - animator._reset(); - }); - - it("rebuilds curve owners and event handlers when state identity changes via remove/re-add", () => { - const localEntity = new Entity(engine); - const localAnimator = localEntity.addComponent(Animator); - const controller = new AnimatorController(engine); - const layer = new AnimatorControllerLayer("layer"); - controller.addLayer(layer); - - // Old state binds rotation.x: 0 → 90 over 1s - const oldState = layer.stateMachine.addState("X"); - const oldClip = new AnimationClip("oldClip"); - const rotationCurve = new AnimationFloatCurve(); - const rk1 = new Keyframe(); - rk1.time = 0; - rk1.value = 0; - const rk2 = new Keyframe(); - rk2.time = 1; - rk2.value = 90; - rotationCurve.addKey(rk1); - rotationCurve.addKey(rk2); - oldClip.addCurveBinding("", Transform, "rotation.x", rotationCurve); - oldState.clip = oldClip; - oldState.wrapMode = WrapMode.Loop; - - localAnimator.animatorController = controller; - localAnimator.play("X"); - // @ts-ignore - localAnimator.engine.time._frameCount++; - localAnimator.update(0.5); - - // First play populates stateData cache keyed by name "X" pointing at oldState's owners. - expect(localEntity.transform.rotation.x).to.be.closeTo(45, 1); - - // Remove + re-add same-name state with a clip targeting a *different* property. - layer.stateMachine.removeState(oldState); - const newState = layer.stateMachine.addState("X"); - const newClip = new AnimationClip("newClip"); - const positionCurve = new AnimationFloatCurve(); - const pk1 = new Keyframe(); - pk1.time = 0; - pk1.value = 0; - const pk2 = new Keyframe(); - pk2.time = 1; - pk2.value = 5; - positionCurve.addKey(pk1); - positionCurve.addKey(pk2); - newClip.addCurveBinding("", Transform, "position.x", positionCurve); - newState.clip = newClip; - newState.wrapMode = WrapMode.Loop; - - // Reset transform so any stale binding shows up as a wrong-property mutation. - localEntity.transform.position = new Vector3(0, 0, 0); - localEntity.transform.rotation = new Vector3(0, 0, 0); - - // @ts-ignore — internal layer data, verify cached state BEFORE second play to confirm stale. - const layerDataBeforeSecondPlay = localAnimator._animatorLayersData[0]; - const stateDataBefore = layerDataBeforeSecondPlay.animatorStateDataMap.get(oldState); - expect(stateDataBefore, "stateData should exist after first play").to.not.eq(undefined); - expect(stateDataBefore.state, "first-play stateData.state must be oldState").to.eq(oldState); - - localAnimator.play("X"); - - // @ts-ignore — internal layer data - const layerData = localAnimator._animatorLayersData[0]; - const stateData = layerData.animatorStateDataMap.get(newState); - // stateData must rebuild against newState identity, not stay aliased to oldState. - expect(stateData.state, "second-play stateData.state must be newState").to.eq(newState); - // The cached curveLayerOwner must point at position.x owner now, not the stale rotation.x owner. - const firstOwnerProp = (stateData.curveLayerOwner[0] as any)?.curveOwner?.property; - expect(firstOwnerProp).to.eq("position.x"); - - // @ts-ignore - localAnimator.engine.time._frameCount++; - localAnimator.update(0.5); - - // After rebuild: position.x ≈ 2.5, rotation.x stays at 0. - // Without rebuild (stale stateData): curveLayerOwner[0] still points at rotation.x owner, - // so position curve value would be applied to rotation.x and position.x would never change. - expect(localEntity.transform.position.x).to.be.closeTo(2.5, 0.5); - expect(localEntity.transform.rotation.x).to.eq(0); - - localEntity.destroy(); - }); - - it("findAnimatorState resets stale layer data after controller mutation", () => { - // Ensure layerData[0] is populated - const handle1 = animator.findAnimatorState("Survey"); - expect(handle1).not.to.eq(null); - - // Mutate the controller — this dispatches the update flag - const controller = animator.animatorController; - const dummyLayer = new AnimatorControllerLayer("__dummy__"); - controller.addLayer(dummyLayer); - - // findAnimatorState should reset stale layerData and rebuild - const handle2 = animator.findAnimatorState("Survey"); - expect(handle2).not.to.eq(null); - expect(handle2).not.to.eq(handle1); // fresh handle after reset - - // Cleanup - controller.removeLayer(controller.layers.indexOf(dummyLayer)); - }); - - it("eventHandlers rebuild lazily when state.clip events change", () => { - const survey = animator.findAnimatorState("Survey"); - expect(survey).not.to.eq(null); - const surveyState = (survey as any)._state; - animator.play("Survey"); - - // @ts-ignore — internal layerData / stateData - const layerData = animator._animatorLayersData[0]; - const stateData = layerData.animatorStateDataMap.get(surveyState); - expect(stateData, "stateData should exist after play").to.not.eq(undefined); - - const versionBefore = stateData.eventsBuiltVersion; - expect(versionBefore).to.be.greaterThanOrEqual(0); - - // Dispatching the clip flag bumps clip's version → next access invalidates eventsBuiltVersion. - surveyState.clip._updateFlagManager.dispatch(); - - // Next play / state-data access triggers _ensureEventHandlersUpToDate and rebuilds. - animator.play("Survey"); - // @ts-ignore - const layerDataAfter = animator._animatorLayersData[0]; - const stateDataAfter = layerDataAfter.animatorStateDataMap.get(surveyState); - expect(stateDataAfter.eventsBuiltVersion).to.be.greaterThan(versionBefore); - }); - - it("crossFade to current state is no-op (avoids src/dest PlayData alias)", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore.playedTime; - - // crossFade to the same state — should be ignored - animator.crossFade("Walk", 0.3, 0, 0); - - expect(layerData.srcPlayData).to.eq(srcBefore); - expect(layerData.srcPlayData.playedTime).to.eq(playedBefore); - expect(layerData.destPlayData).to.eq(null); - }); - - it("crossFade to currently-fading dest state is no-op", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - animator.crossFade("Run", 0.5, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const destBefore = layerData.destPlayData; - const destPlayedBefore = destBefore.playedTime; - - // crossFade to the in-flight dest state — should be ignored - animator.crossFade("Run", 0.3, 0, 0); - - expect(layerData.destPlayData).to.eq(destBefore); - expect(layerData.destPlayData.playedTime).to.eq(destPlayedBefore); - }); - - it("state-machine self-transition is also a no-op (alias-guard policy)", () => { - const walk = animator.findAnimatorState("Walk"); - (walk as any)._state.clearTransitions(); - animator.animatorController.addParameter("restart", false); - - const selfTransition = (walk as any)._state.addTransition((walk as any)._state); - selfTransition.hasExitTime = false; - selfTransition.duration = 0.1; - selfTransition.addCondition("restart", AnimatorConditionMode.If, true); - - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore.playedTime; - - // Trigger the self-transition - animator.setParameterValue("restart", true); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // Self-transition is intentionally a no-op (one persistent PlayData per state). - // src should keep advancing as if no transition happened, dest stays null. - expect(layerData.srcPlayData).to.eq(srcBefore); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.be.greaterThan(playedBefore); - expect(layerData.destPlayData).to.eq(null); - }); - - it("play during crossFade clears stale destPlayData", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - animator.crossFade("Run", 0.5, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // Interrupt the in-flight crossFade with a play() - animator.play("Survey"); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - expect(layerData.destPlayData).to.eq(null); - expect(layerData.crossFadeTransition).to.eq(null); - - // A subsequent crossFade to the previously-fading state should now succeed — - // the stale dest slot must not block it via the alias guard. - animator.crossFade("Run", 0.3, 0, 0); - expect(layerData.destPlayData?.instance.name).to.eq("Run"); - }); - - it("crossFade to nonexistent state is a safe no-op", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - const before = animator.getCurrentAnimatorState(0); - animator.crossFade("MissingState", 0.3, 0, 0); - const after = animator.getCurrentAnimatorState(0); - - expect(after).to.eq(before); - // @ts-ignore — verify no junk layerData was written at array index -1. - expect(animator._animatorLayersData[-1]).to.eq(undefined); - }); - - it("findAnimatorState with out-of-range layerIndex returns null", () => { - expect(animator.findAnimatorState("Survey", 99)).to.eq(null); - expect(animator.findAnimatorState("Survey", -2)).to.eq(null); - }); - - it("play / crossFade with out-of-range layerIndex are safe no-ops", () => { - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.001); - - const stateBefore = animator.getCurrentAnimatorState(0); - - expect(() => animator.play("Walk", 99)).not.to.throw(); - expect(() => animator.crossFade("Run", 0.3, 99, 0)).not.to.throw(); - - expect(animator.getCurrentAnimatorState(0)).to.eq(stateBefore); - // @ts-ignore — verify no junk layerData created at index -1 / 99 - expect(animator._animatorLayersData[-1]).to.eq(undefined); - // @ts-ignore - expect(animator._animatorLayersData[99]).to.eq(undefined); - }); - - it("transition out of a state with per-instance speed 0 does not produce NaN", () => { - const survey = animator.findAnimatorState("Survey"); - survey.speed = 0; // pause this state per-instance - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // crossFade out — destination state should still progress despite src speed=0 - animator.crossFade("Walk", 0.3, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - expect(Number.isNaN(layerData.srcPlayData.playedTime)).to.eq(false); - expect(Number.isNaN(layerData.destPlayData?.playedTime ?? 0)).to.eq(false); - // Walk dest should have progressed - expect(layerData.destPlayData?.playedTime).to.be.greaterThan(0); - }); - - it("no-exit transition out of speed=0 source preserves remaining deltaTime and avoids NaN", () => { - const survey = animator.findAnimatorState("Survey"); - const walk = animator.findAnimatorState("Walk"); - (survey as any)._state.clearTransitions(); - (walk as any)._state.clearTransitions(); - animator.animatorController.addParameter("goWalk", false); - - survey.speed = 0; // pause source per-instance - - const transition = (survey as any)._state.addTransition((walk as any)._state); - transition.hasExitTime = false; - transition.duration = 0.3; - transition.addCondition("goWalk", AnimatorConditionMode.If, true); - - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - animator.setParameterValue("goWalk", true); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - expect(Number.isNaN(layerData.srcPlayData.playedTime)).to.eq(false); - expect(Number.isNaN(layerData.destPlayData?.playedTime ?? 0)).to.eq(false); - expect(layerData.destPlayData?.instance.name).to.eq("Walk"); - // dest should have advanced from the remaining deltaTime that was - // preserved by the playSpeed===0 guard - expect(layerData.destPlayData?.playedTime).to.be.greaterThan(0); - }); }); diff --git a/tests/src/core/AnimatorHang.test.ts b/tests/src/core/AnimatorHang.test.ts new file mode 100644 index 0000000000..99a8a5ba14 --- /dev/null +++ b/tests/src/core/AnimatorHang.test.ts @@ -0,0 +1,20 @@ +import { Animator, Camera } from "@galacean/engine-core"; +import "@galacean/engine-loader"; +import type { GLTFResource } from "@galacean/engine-loader"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { describe, expect, it } from "vitest"; +import { glbResource } from "./model/fox"; +const canvasDOM = document.createElement("canvas"); +canvasDOM.width = 1024; +canvasDOM.height = 1024; +describe("Canvas 1024 test", async function () { + const engine = await WebGLEngine.create({ canvas: canvasDOM }); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + rootEntity.addComponent(Camera); + const resource = await engine.resourceManager.load(glbResource); + const defaultSceneRoot = resource.defaultSceneRoot; + rootEntity.addChild(defaultSceneRoot); + const animator = defaultSceneRoot.getComponent(Animator); + it("loaded", () => { expect(animator).not.eq(null); }); +}); diff --git a/tests/src/core/audio/AudioSource.test.ts b/tests/src/core/audio/AudioSource.test.ts index dd8fa6c6f1..875c8035d1 100644 --- a/tests/src/core/audio/AudioSource.test.ts +++ b/tests/src/core/audio/AudioSource.test.ts @@ -1,6 +1,6 @@ import { AssetType, AudioClip, AudioManager, AudioSource, Engine } from "@galacean/engine-core"; import "@galacean/engine-loader"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { beforeAll, describe, expect, it } from "vitest"; import { sound } from "../model/sound"; diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts new file mode 100644 index 0000000000..5e7e4645a1 --- /dev/null +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AudioManager, AudioSource } from "@galacean/engine-core"; + +class MockGainNode { + gain = { + setValueAtTime: vi.fn() + }; + + connect = vi.fn(); +} + +class MockBufferSourceNode { + buffer: unknown = null; + loop = false; + onended: (() => void) | null = null; + playbackRate = { + value: 1 + }; + + connect = vi.fn(); + disconnect = vi.fn(); + start = vi.fn(); + stop = vi.fn(); +} + +class MockAudioContext { + static shouldResumeSucceed = true; + static resumeResultQueue: Array | Error> | null = null; + + currentTime = 0; + destination = {}; + onstatechange: (() => void) | null = null; + state: AudioContextState = "suspended"; + + createBufferSource(): AudioBufferSourceNode { + return new MockBufferSourceNode() as unknown as AudioBufferSourceNode; + } + + createGain(): GainNode { + return new MockGainNode() as unknown as GainNode; + } + + resume(): Promise { + const queuedResult = MockAudioContext.resumeResultQueue?.shift(); + if (queuedResult instanceof Promise) { + return queuedResult; + } + if (queuedResult instanceof Error) { + return Promise.reject(queuedResult); + } + if (!MockAudioContext.shouldResumeSucceed) { + return Promise.reject(new Error("autoplay blocked")); + } + this.state = "running"; + this.onstatechange?.(); + return Promise.resolve(); + } + + suspend(): Promise { + this.state = "suspended"; + this.onstatechange?.(); + return Promise.resolve(); + } +} + +async function flushAsync(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function createAudioSource(): AudioSource { + const audioSource = new AudioSource({ + _isActiveInHierarchy: true, + _isActiveInScene: true, + _removeComponent() {}, + engine: {} + } as any); + + audioSource.clip = { + _addReferCount() {}, + _getAudioSource() { + return {}; + } + } as any; + + return audioSource; +} + +describe("AudioSource pending playback", () => { + beforeEach(() => { + (window as any).AudioContext = MockAudioContext; + (AudioManager as any)._context = null; + (AudioManager as any)._gainNode = null; + (AudioManager as any)._needsUserGestureResume = false; + (AudioManager as any)._pendingSources = new Set(); + (AudioManager as any)._playingSources = new Set(); + (AudioManager as any)._interruptedSources = new Set(); + (AudioManager as any)._foregroundRestoreTimer = undefined; + (AudioManager as any)._hidden = false; + MockAudioContext.shouldResumeSucceed = true; + MockAudioContext.resumeResultQueue = null; + AudioManager._playingCount = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.replaceChildren(); + }); + + it("replays pending playback on the next user gesture after autoplay blocking", async () => { + const audioSource = createAudioSource(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + MockAudioContext.shouldResumeSucceed = false; + + audioSource.play(); + await flushAsync(); + + expect((audioSource as any)._pendingPlay).to.be.true; + expect((AudioManager as any)._pendingSources.size).to.equal(1); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(audioSource.isPlaying).to.be.false; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._pendingSources.size).to.equal(0); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("cancels pending playback before the unlocking gesture arrives", async () => { + const audioSource = createAudioSource(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + MockAudioContext.shouldResumeSucceed = false; + + audioSource.play(); + await flushAsync(); + + audioSource.stop(); + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._pendingSources.size).to.equal(0); + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; + }); + + it("keeps resume a no-op until a context already exists", async () => { + expect((AudioManager as any)._context).to.be.null; + + await AudioManager.resume(); + + expect((AudioManager as any)._context).to.be.null; + }); + + it("does not resume foreground audio before a hide event", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + const resumeSpy = vi.spyOn(context, "resume"); + const suspendSpy = vi.spyOn(AudioManager, "suspend"); + + context.state = "suspended"; + AudioManager._playingCount = 1; + + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(suspendSpy).not.toHaveBeenCalled(); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("recreates interrupted source nodes from a foreground gesture", async () => { + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "running"; + audioSource.play(); + + const firstSourceNode = (audioSource as any)._sourceNode as MockBufferSourceNode; + expect(audioSource.isPlaying).to.be.true; + expect(AudioManager._playingCount).to.equal(1); + + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(firstSourceNode.stop).toHaveBeenCalledTimes(1); + expect(audioSource.isPlaying).to.be.false; + expect(AudioManager._playingCount).to.equal(0); + expect((AudioManager as any)._interruptedSources.size).to.equal(1); + + hiddenSpy.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._interruptedSources.size).to.equal(1); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + document.dispatchEvent(new Event("touchend")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect(AudioManager._playingCount).to.equal(1); + expect((AudioManager as any)._interruptedSources.size).to.equal(0); + expect((audioSource as any)._sourceNode).not.to.equal(firstSourceNode); + }); + + it("recovers interrupted source nodes from foreground retry after the restore delay", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "running"; + audioSource.play(); + + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + hiddenSpy.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + + await vi.advanceTimersByTimeAsync(299); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + + await vi.advanceTimersByTimeAsync(1); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._interruptedSources.size).to.equal(0); + }); + + it("handles document pagehide/pageshow and mouseup recovery", async () => { + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "running"; + audioSource.play(); + + document.dispatchEvent(new Event("pagehide")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._interruptedSources.size).to.equal(1); + + document.dispatchEvent(new Event("pageshow")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + document.dispatchEvent(new Event("mouseup")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._interruptedSources.size).to.equal(0); + }); + + it("keeps gesture recovery when foreground resume fails", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + vi.spyOn(console, "warn").mockImplementation(() => {}); + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); + const resumeSpy = vi.spyOn(context, "resume"); + const suspendSpy = vi.spyOn(AudioManager, "suspend"); + + context.state = "running"; + audioSource.play(); + + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + MockAudioContext.shouldResumeSucceed = false; + hiddenSpy.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(suspendSpy).toHaveBeenCalledTimes(2); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + await vi.advanceTimersByTimeAsync(299); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(suspendSpy).toHaveBeenCalledTimes(2); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + await vi.advanceTimersByTimeAsync(1); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(suspendSpy).toHaveBeenCalledTimes(3); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(3); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("retries context.resume inside a later user gesture even if an earlier resume is still pending", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + const firstResume = new Promise(() => {}); + + MockAudioContext.resumeResultQueue = [firstResume]; + const resumeSpy = vi.spyOn(context, "resume"); + + AudioManager.resume().catch(() => {}); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + + MockAudioContext.resumeResultQueue = [ + Promise.resolve().then(() => { + context.state = "running"; + context.onstatechange?.(); + }) + ]; + (AudioManager as any)._needsUserGestureResume = true; + + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); +}); diff --git a/tests/src/core/particle/ParticleTextureSheetAnimation.test.ts b/tests/src/core/particle/ParticleTextureSheetAnimation.test.ts index 250ba08537..934d1f1082 100644 --- a/tests/src/core/particle/ParticleTextureSheetAnimation.test.ts +++ b/tests/src/core/particle/ParticleTextureSheetAnimation.test.ts @@ -1,50 +1,52 @@ - import { Camera, ParticleRenderer, Scene } from "@galacean/engine-core"; import { Vector2 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { beforeAll, describe, expect, it } from "vitest"; describe("TextureSheetAnimation Test", () => { - let engine: WebGLEngine; - let scene: Scene; - let renderer: ParticleRenderer; - - beforeAll(async function () { - engine = await WebGLEngine.create({ - canvas: document.createElement("canvas") - }); - - scene = engine.sceneManager.activeScene; - const rootEntity = scene.createRootEntity("root"); - - const cameraEntity = rootEntity.createChild("Camera"); - cameraEntity.addComponent(Camera); - cameraEntity.transform.setPosition(0, 0, 10); - - renderer = scene.createRootEntity("Renderer").addComponent(ParticleRenderer); - engine.run(); - }); - it("Tiling", () => { - const textureSheetAnimation = renderer.generator.textureSheetAnimation; - textureSheetAnimation.tiling = new Vector2(2, 2); - expect(textureSheetAnimation.tiling).to.deep.include({ x: 2, y: 2 }); - // @ts-ignore - expect(textureSheetAnimation._tillingInfo).to.deep.include({ x: 0.5, y: 0.5, z: 4 }); + let engine: WebGLEngine; + let scene: Scene; + let renderer: ParticleRenderer; - textureSheetAnimation.tiling.set(1, 1); - expect(textureSheetAnimation.tiling).to.deep.include({ x: 1, y: 1 }); - // @ts-ignore - expect(textureSheetAnimation._tillingInfo).to.deep.include({ x: 1, y: 1, z: 1 }); + beforeAll(async function () { + engine = await WebGLEngine.create({ + canvas: document.createElement("canvas") }); - it("Clone", () => { - const textureSheetAnimation = renderer.generator.textureSheetAnimation; - textureSheetAnimation.tiling = new Vector2(4, 4); - const cloneTextureSheetAnimation = renderer.entity.clone().getComponent(ParticleRenderer).generator.textureSheetAnimation; - expect(cloneTextureSheetAnimation.tiling).to.deep.include({ x: 4, y: 4 }); - // @ts-ignore - expect(cloneTextureSheetAnimation.tiling._onValueChanged).to.not.equal(textureSheetAnimation.tiling._onValueChanged); - // @ts-ignore - expect(cloneTextureSheetAnimation._tillingInfo).to.deep.include({ x: 0.25, y: 0.25, z: 16 }); - }) -}); \ No newline at end of file + scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity("root"); + + const cameraEntity = rootEntity.createChild("Camera"); + cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 10); + + renderer = scene.createRootEntity("Renderer").addComponent(ParticleRenderer); + engine.run(); + }); + it("Tiling", () => { + const textureSheetAnimation = renderer.generator.textureSheetAnimation; + textureSheetAnimation.tiling = new Vector2(2, 2); + expect(textureSheetAnimation.tiling).to.deep.include({ x: 2, y: 2 }); + // @ts-ignore + expect(textureSheetAnimation._tillingInfo).to.deep.include({ x: 0.5, y: 0.5, z: 4 }); + + textureSheetAnimation.tiling.set(1, 1); + expect(textureSheetAnimation.tiling).to.deep.include({ x: 1, y: 1 }); + // @ts-ignore + expect(textureSheetAnimation._tillingInfo).to.deep.include({ x: 1, y: 1, z: 1 }); + }); + + it("Clone", () => { + const textureSheetAnimation = renderer.generator.textureSheetAnimation; + textureSheetAnimation.tiling = new Vector2(4, 4); + const cloneTextureSheetAnimation = renderer.entity.clone().getComponent(ParticleRenderer) + .generator.textureSheetAnimation; + expect(cloneTextureSheetAnimation.tiling).to.deep.include({ x: 4, y: 4 }); + // @ts-ignore + expect(cloneTextureSheetAnimation.tiling._onValueChanged).to.not.equal( + textureSheetAnimation.tiling._onValueChanged + ); + // @ts-ignore + expect(cloneTextureSheetAnimation._tillingInfo).to.deep.include({ x: 0.25, y: 0.25, z: 16 }); + }); +}); diff --git a/tests/src/core/particle/RateOverTimeReplay.test.ts b/tests/src/core/particle/RateOverTimeReplay.test.ts index 6e00d380da..8973117d29 100644 --- a/tests/src/core/particle/RateOverTimeReplay.test.ts +++ b/tests/src/core/particle/RateOverTimeReplay.test.ts @@ -1,11 +1,4 @@ -import { - Camera, - Engine, - Entity, - ParticleMaterial, - ParticleRenderer, - ParticleStopMode -} from "@galacean/engine-core"; +import { Camera, Engine, Entity, ParticleMaterial, ParticleRenderer, ParticleStopMode } from "@galacean/engine-core"; import { Color } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; diff --git a/tests/src/core/physics/CharacterController.test.ts b/tests/src/core/physics/CharacterController.test.ts index e5eb69e543..6c7d518d07 100644 --- a/tests/src/core/physics/CharacterController.test.ts +++ b/tests/src/core/physics/CharacterController.test.ts @@ -13,7 +13,7 @@ import { Layer, ColliderShapeUpAxis } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { Quaternion, Vector3 } from "@galacean/engine-math"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; diff --git a/tests/src/core/physics/Collider.test.ts b/tests/src/core/physics/Collider.test.ts index 4a610d6484..bb44901374 100644 --- a/tests/src/core/physics/Collider.test.ts +++ b/tests/src/core/physics/Collider.test.ts @@ -12,7 +12,7 @@ import { import { Vector3 } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { LitePhysics } from "@galacean/engine-physics-lite"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { vi, describe, beforeAll, beforeEach, expect, it, afterEach } from "vitest"; class CollisionScript extends Script { diff --git a/tests/src/core/physics/ColliderShape.test.ts b/tests/src/core/physics/ColliderShape.test.ts index d4c65c4c25..ce2b15a43f 100644 --- a/tests/src/core/physics/ColliderShape.test.ts +++ b/tests/src/core/physics/ColliderShape.test.ts @@ -9,7 +9,7 @@ import { PhysicsMaterial } from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; import { LitePhysics } from "@galacean/engine-physics-lite"; diff --git a/tests/src/core/physics/Collision.test.ts b/tests/src/core/physics/Collision.test.ts index 54b8f3809c..6f7e886df3 100644 --- a/tests/src/core/physics/Collision.test.ts +++ b/tests/src/core/physics/Collision.test.ts @@ -1,7 +1,16 @@ -import { BoxColliderShape, DynamicCollider, Entity, Engine, Script, StaticCollider } from "@galacean/engine-core"; +import { + BoxColliderShape, + DynamicCollider, + DynamicColliderConstraints, + Entity, + Engine, + Script, + SphereColliderShape, + StaticCollider +} from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { Collision } from "packages/core/types/physics/Collision"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; @@ -22,6 +31,20 @@ describe("Collision", function () { return boxEntity; } + function addSphere(radius: number, pos: Vector3) { + const sphereEntity = rootEntity.createChild("SphereEntity"); + sphereEntity.transform.setPosition(pos.x, pos.y, pos.z); + + const sphereShape = new SphereColliderShape(); + sphereShape.material.dynamicFriction = 0; + sphereShape.material.staticFriction = 0; + sphereShape.radius = radius; + const sphereCollider = sphereEntity.addComponent(DynamicCollider); + sphereCollider.addShape(sphereShape); + sphereCollider.useGravity = false; + return sphereEntity; + } + function formatValue(value: number) { return Math.round(value * 100000) / 100000; } @@ -164,4 +187,239 @@ describe("Collision", function () { engine.sceneManager.activeScene.physics._update(1); }); }); + + it("reports contact normal from static other shape to dynamic self shape", function () { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const dynamicBox = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const staticBox = addBox(new Vector3(1, 1, 1), StaticCollider, new Vector3(0, 0, 0)); + + return new Promise((done) => { + dynamicBox.addComponent( + class extends Script { + onCollisionEnter(other: Collision): void { + expect(other.shape).toBe(staticBox.getComponent(StaticCollider).shapes[0]); + const contacts = []; + other.getContacts(contacts); + expect(contacts.length).toBeGreaterThan(0); + expect(formatValue(contacts[0].normal.x)).toBe(-1); + + done(); + } + } + ); + + dynamicBox.getComponent(DynamicCollider).applyForce(new Vector3(1000, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + }); + }); + + it("reports billiard hitBall sphere normal from kinematic other to dynamic target self", function () { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const targetBall = addSphere(0.5, new Vector3(0, 0, 0)); + const hitBall = addSphere(0.5, new Vector3(-3, 0, 0)); + const hitCollider = hitBall.getComponent(DynamicCollider); + hitCollider.isKinematic = true; + + return new Promise((done) => { + targetBall.addComponent( + class extends Script { + onCollisionEnter(other: Collision): void { + expect(other.shape).toBe(hitCollider.shapes[0]); + const contacts = []; + other.getContacts(contacts); + expect(contacts.length).toBeGreaterThan(0); + + const contactNormal = contacts[0].normal; + expect(formatValue(contactNormal.x)).toBe(1); + expect(formatValue(contactNormal.y)).toBe(0); + expect(formatValue(contactNormal.z)).toBe(0); + + const hitToTarget = new Vector3(); + Vector3.subtract(targetBall.transform.worldPosition, hitBall.transform.worldPosition, hitToTarget); + hitToTarget.normalize(); + expect(formatValue(Vector3.dot(contactNormal, hitToTarget))).toBe(1); + + const scaledNormal = new Vector3(); + Vector3.scale(contactNormal, 2 * Vector3.dot(hitToTarget, contactNormal), scaledNormal); + const reflected = new Vector3(); + Vector3.subtract(hitToTarget, scaledNormal, reflected); + reflected.normalize(); + expect(formatValue(reflected.x)).toBe(-1); + + done(); + } + } + ); + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + hitCollider.move(new Vector3(-0.9, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + }); + }); + + // ────────────────────────────────────────────────────────────────────────────── + // Kinematic-pair collision callback (3D billiard aim-line use case). + // PhysX 4.x defaults suppress kineKine + staticKine pairs. SceneBinding already + // sets kineKineFilteringMode = eKEEP and staticKineFilteringMode = eKEEP, and + // filter shader returns eNOTIFY_TOUCH_FOUND/PERSISTS/LOST for all pairs. + // Question: does the kinematic pair actually fire onCollisionEnter when a + // kinematic actor is moved into another actor's volume via setWorldTransform + // (i.e. setGlobalPose teleport, NOT setKinematicTarget)? + // + // Cocos parity expectation: yes — Cocos PhysX backend fires onCollisionEnter + // for kinematic↔dynamic and kinematic↔kinematic pairs on overlap. + // ────────────────────────────────────────────────────────────────────────────── + + function probeKinematicCallback(opts: { + aKine: boolean; + bKine: boolean; + timeoutMs?: number; + }): Promise<{ fired: boolean }> { + return new Promise((resolve) => { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const boxA = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const boxB = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(3, 0, 0)); + const colA = boxA.getComponent(DynamicCollider); + const colB = boxB.getComponent(DynamicCollider); + colA.useGravity = false; + colB.useGravity = false; + colA.isKinematic = opts.aKine; + colB.isKinematic = opts.bKine; + + let fired = false; + boxA.addComponent( + class extends Script { + onCollisionEnter(_other: Collision): void { + fired = true; + resolve({ fired: true }); + } + } + ); + + // Step a few frames to let PhysX settle initial state. + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // Teleport B onto A → expect onCollisionEnter. + boxB.transform.setPosition(-3, 0, 0); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + if (!fired) resolve({ fired: false }); + }); + } + + // Probes that the standard transform→PhysX sync path routes correctly for + // kinematic actors. With the fix in PhysXDynamicCollider.setWorldTransform, + // moving a kinematic actor via transform.setPosition() goes through + // setKinematicTarget(), which lets PhysX detect contact and fire the callback. + it("kinematic-kinematic overlap via transform.setPosition fires onCollisionEnter", async function () { + const r = await probeKinematicCallback({ aKine: true, bKine: true }); + expect(r.fired).toBe(true); + }); + + it("kinematic-dynamic overlap via transform.setPosition fires onCollisionEnter", async function () { + const r = await probeKinematicCallback({ aKine: true, bKine: false }); + expect(r.fired).toBe(true); + }); + + it("dynamic-dynamic overlap via transform.setPosition fires onCollisionEnter", async function () { + const r = await probeKinematicCallback({ aKine: false, bKine: false }); + expect(r.fired).toBe(true); + }); + + // Probe whether "dynamic actor + freeze 6 constraints + teleport via setGlobalPose" + // can substitute for a kinematic actor and still trigger contact callbacks. + // This is the proposed fix path for the 3D billiard hitBall: ditch kinematic, + // use a fully-frozen dynamic actor that is moved via setWorldPosition. + it("HYPOTHESIS: kine-kine fires onCollisionEnter when moved via setKinematicTarget (not setGlobalPose)", function () { + return new Promise((resolve, reject) => { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const boxA = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const boxB = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(3, 0, 0)); + const colA = boxA.getComponent(DynamicCollider); + const colB = boxB.getComponent(DynamicCollider); + colA.useGravity = false; + colB.useGravity = false; + colA.isKinematic = true; + colB.isKinematic = true; + + let fired = false; + boxA.addComponent( + class extends Script { + onCollisionEnter(_other: Collision): void { + fired = true; + resolve(); + } + } + ); + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // Move B onto A via DynamicCollider.move() — this internally calls setKinematicTarget. + colB.move(new Vector3(-3, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + if (!fired) reject(new Error("kine-kine setKinematicTarget did NOT fire onCollisionEnter")); + }); + }); + + it("dynamic + frozen-6 + teleport: overlap fires onCollisionEnter (fix candidate)", function () { + return new Promise((resolve) => { + engine.sceneManager.activeScene.physics.gravity = new Vector3(0, 0, 0); + const boxA = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const boxB = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(3, 0, 0)); + const colA = boxA.getComponent(DynamicCollider); + const colB = boxB.getComponent(DynamicCollider); + // Both fully frozen — emulates the cocos kinematic semantics (no gravity, no movement). + const FREEZE_ALL = + DynamicColliderConstraints.FreezePositionX | + DynamicColliderConstraints.FreezePositionY | + DynamicColliderConstraints.FreezePositionZ | + DynamicColliderConstraints.FreezeRotationX | + DynamicColliderConstraints.FreezeRotationY | + DynamicColliderConstraints.FreezeRotationZ; + colA.constraints = FREEZE_ALL; + colB.constraints = FREEZE_ALL; + colA.useGravity = false; + colB.useGravity = false; + colA.isKinematic = false; + colB.isKinematic = false; + + let fired = false; + boxA.addComponent( + class extends Script { + onCollisionEnter(_other: Collision): void { + fired = true; + resolve(); + } + } + ); + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + boxB.transform.setPosition(-3, 0, 0); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + if (!fired) { + expect.fail("expected onCollisionEnter to fire for dynamic-frozen pair after teleport"); + } + }); + }); }); diff --git a/tests/src/core/physics/DynamicCollider.test.ts b/tests/src/core/physics/DynamicCollider.test.ts index ecde06ef3d..b441f300d4 100644 --- a/tests/src/core/physics/DynamicCollider.test.ts +++ b/tests/src/core/physics/DynamicCollider.test.ts @@ -6,10 +6,11 @@ import { DynamicCollider, DynamicColliderConstraints, CollisionDetectionMode, + DynamicColliderKinematicTransformSyncMode, StaticCollider, PlaneColliderShape } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { Vector3 } from "@galacean/engine-math"; import { vi, describe, beforeAll, beforeEach, expect, it } from "vitest"; @@ -278,6 +279,256 @@ describe("DynamicCollider", function () { expect(formatValue(boxCollider.inertiaTensor.y)).eq(1); }); + it("applyForceAtPosition - at center of mass produces only linear acceleration", function () { + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + boxCollider.centerOfMass = new Vector3(0, 0, 0); + boxCollider.inertiaTensor = new Vector3(1, 1, 1); + + boxCollider.applyForceAtPosition(new Vector3(1, 0, 0), new Vector3(0, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + + expect(formatValue(boxCollider.linearVelocity.x)).eq(0.01667); + expect(formatValue(boxCollider.angularVelocity.x)).eq(0); + expect(formatValue(boxCollider.angularVelocity.y)).eq(0); + expect(formatValue(boxCollider.angularVelocity.z)).eq(0); + }); + + it("applyForceAtPosition - offset produces torque = (position - CoM) × force", function () { + // Reference: same physical setup but using applyForce + applyTorque(τ) directly. + // applyForceAtPosition(F, P) must produce the same result. + const setupBox = () => { + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const collider = box.getComponent(DynamicCollider); + collider.mass = 1; + collider.useGravity = false; + collider.centerOfMass = new Vector3(0, 0, 0); + collider.inertiaTensor = new Vector3(1, 1, 1); + return collider; + }; + + const force = new Vector3(1, 0, 0); + const worldPos = new Vector3(0, 1, 0); // r = (0,1,0) - (0,0,0) = (0,1,0); r × F = (0,0,-1) + + const reference = setupBox(); + reference.applyForce(force); + reference.applyTorque(new Vector3(0, 0, -1)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + const refLinear = reference.linearVelocity.clone(); + const refAngular = reference.angularVelocity.clone(); + + rootEntity.clearChildren(); + + const target = setupBox(); + target.applyForceAtPosition(force, worldPos); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + + expect(formatValue(target.linearVelocity.x)).eq(formatValue(refLinear.x)); + expect(formatValue(target.linearVelocity.y)).eq(formatValue(refLinear.y)); + expect(formatValue(target.linearVelocity.z)).eq(formatValue(refLinear.z)); + expect(formatValue(target.angularVelocity.x)).eq(formatValue(refAngular.x)); + expect(formatValue(target.angularVelocity.y)).eq(formatValue(refAngular.y)); + expect(formatValue(target.angularVelocity.z)).eq(formatValue(refAngular.z)); + expect(formatValue(target.angularVelocity.z)).lessThan(0); + }); + + it("applyForceAtPosition - respects centerOfMass offset (no torque when applied at CoM)", function () { + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + // Shift CoM to local (1, 0, 0). With entity at origin and identity rotation, world CoM = (1, 0, 0). + boxCollider.centerOfMass = new Vector3(1, 0, 0); + boxCollider.inertiaTensor = new Vector3(1, 1, 1); + + // Applying force at world (1,0,0) means r = 0 → no torque. + boxCollider.applyForceAtPosition(new Vector3(0, 0, 1), new Vector3(1, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + + expect(formatValue(boxCollider.linearVelocity.z)).eq(0.01667); + expect(formatValue(boxCollider.angularVelocity.x)).eq(0); + expect(formatValue(boxCollider.angularVelocity.y)).eq(0); + expect(formatValue(boxCollider.angularVelocity.z)).eq(0); + }); + + it("applyForceAtPosition - respects entity world rotation when transforming local CoM", function () { + // entity rotated 90° around Y, CoM local (1, 0, 0) → world CoM offset (0, 0, -1) + // Apply force at world (0,0,-1), r=0, expect no torque. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + box.transform.rotate(new Vector3(0, 90, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + boxCollider.centerOfMass = new Vector3(1, 0, 0); + boxCollider.inertiaTensor = new Vector3(1, 1, 1); + + boxCollider.applyForceAtPosition(new Vector3(1, 0, 0), new Vector3(0, 0, -1)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + + expect(formatValue(boxCollider.linearVelocity.x)).eq(0.01667); + expect(formatValue(boxCollider.angularVelocity.x)).eq(0); + expect(formatValue(boxCollider.angularVelocity.y)).eq(0); + expect(formatValue(boxCollider.angularVelocity.z)).eq(0); + }); + + it("applyForceAtPosition - position + rotation + CoM offset + r != 0 (full worldCoM coverage)", function () { + // Stress-test all three terms of worldCoM = entity.worldPos + worldRot * localCoM: + // entity position (5, 0, 0) — exercises the translation term + // entity rotation 90° around Y — exercises the rotation term (localCoM (1,0,0) → world (0,0,-1)) + // CoM local (1, 0, 0) — exercises the local CoM lookup + // worldCoM = (5,0,0) + (0,0,-1) = (5, 0, -1) + // force F = (1, 0, 0) at P = (5, 1, -1) + // r = P - worldCoM = (0, 1, 0) + // τ = r × F = (0, 0, -1) + // If any single term in worldCoM is wrong (missing translate / wrong quat / wrong localCoM), + // r becomes non-(0,1,0) and τ has spurious x/y components → catches the bug. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(5, 0, 0)); + box.transform.rotate(new Vector3(0, 90, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + boxCollider.centerOfMass = new Vector3(1, 0, 0); + boxCollider.inertiaTensor = new Vector3(1, 1, 1); + + boxCollider.applyForceAtPosition(new Vector3(1, 0, 0), new Vector3(5, 1, -1)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + + expect(formatValue(boxCollider.linearVelocity.x)).eq(0.01667); + expect(formatValue(boxCollider.angularVelocity.x)).eq(0); + expect(formatValue(boxCollider.angularVelocity.y)).eq(0); + expect(formatValue(boxCollider.angularVelocity.z)).lessThan(0); + }); + + it("applyForceAtPosition - works after entity.clone() (defends prefab clone path)", function () { + // R6/R8 both surfaced because clone() bypasses setters and leaves native state inconsistent. + // applyForceAtPosition reads native getCenterOfMass + entity.transform.worldRotationQuaternion. + // If a future change adds @ignoreClone fields or relies on setter side-effects, this test + // will catch the regression by exercising the API on a cloned collider. + const source = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const sourceCollider = source.getComponent(DynamicCollider); + sourceCollider.mass = 1; + sourceCollider.useGravity = false; + sourceCollider.centerOfMass = new Vector3(0, 0, 0); + sourceCollider.inertiaTensor = new Vector3(1, 1, 1); + + const cloneEntity = source.clone(); + source.destroy(); + rootEntity.addChild(cloneEntity); + cloneEntity.transform.setPosition(0, 0, 0); + + const cloneCollider = cloneEntity.getComponent(DynamicCollider); + // r = (0,1,0), F = (1,0,0), τ = (0,0,-1) → expect negative angular z + cloneCollider.applyForceAtPosition(new Vector3(1, 0, 0), new Vector3(0, 1, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1); + + expect(formatValue(cloneCollider.linearVelocity.x)).eq(0.01667); + expect(formatValue(cloneCollider.angularVelocity.z)).lessThan(0); + expect(formatValue(cloneCollider.angularVelocity.x)).eq(0); + expect(formatValue(cloneCollider.angularVelocity.y)).eq(0); + }); + + it("applyForce on sleeping actor must wake up and apply force", function () { + // Validates whether PhysX wasm `addForce(force, eFORCE, autowake=true)` actually wakes a + // sleeping actor on its own — or whether the engine's explicit wakeUp() call is required. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + boxCollider.linearDamping = 0; + + boxCollider.sleep(); + expect(boxCollider.isSleeping()).toBe(true); + + boxCollider.applyForce(new Vector3(1, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + expect(formatValue(boxCollider.linearVelocity.x)).eq(0.01667); + expect(boxCollider.isSleeping()).toBe(false); + }); + + it("applyForce after kinematic→dynamic switch (mimic billiards game break flow)", function () { + // Game pattern: all balls set kinematic at init, switched back to dynamic on break, + // then applyForce. Verifies the original 'force lost' bug was actually from this path. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.mass = 1; + boxCollider.useGravity = false; + boxCollider.linearDamping = 0; + + boxCollider.isKinematic = true; + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + boxCollider.isKinematic = false; + + boxCollider.applyForce(new Vector3(1, 0, 0)); + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + expect(formatValue(boxCollider.linearVelocity.x)).eq(0.01667); + }); + + it("fixedTimeStep 1/60 vs 1/480: PhysX applyForce delivers 8x smaller dv at finer step", function () { + // ultrathink probe: does cocos-style `fixedTimeStep(true)→1/480` actually give + // *more force* than `fixedTimeStep(false)→1/60` in PhysX? + // + // Theory: + // Bullet (Cocos): clearForces runs ONCE after all substeps → dv = F·frame_dt/m + // → substep count doesn't affect dv. + // PhysX (Galacean): force cleared per simulate() call → only the first substep + // in a frame applies the force → dv = F·fixedTimeStep/m + // → 1/480 gives dv 8x smaller than 1/60. + // + // This test confirms the PhysX behavior empirically and quantifies the gap. + const scene = engine.sceneManager.activeScene; + const originalFTS = scene.physics.fixedTimeStep; + + const probe = (fts: number) => { + rootEntity.clearChildren(); + scene.physics.fixedTimeStep = fts; + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const c = box.getComponent(DynamicCollider); + c.mass = 1; + c.useGravity = false; + c.linearDamping = 0; + c.angularDamping = 0; + c.applyForce(new Vector3(100, 0, 0)); + // Advance exactly one *frame* of wall time. Galacean's _update loops simulate + // until frame_dt accumulates: 1/60 → 1 substep; 1/480 → 8 substeps. + // @ts-ignore + scene.physics._update(1 / 60); + return c.linearVelocity.x; + }; + + const dv_1_60 = probe(1 / 60); + const dv_1_480 = probe(1 / 480); + + console.info( + `[fixedTimeStep probe] applyForce(F=100) over 1 frame (1/60s):\n` + + ` 1/60 step → dv = ${dv_1_60.toFixed(4)} m/s\n` + + ` 1/480 step → dv = ${dv_1_480.toFixed(4)} m/s\n` + + ` ratio (1/60 / 1/480) = ${(dv_1_60 / dv_1_480).toFixed(3)} (theory: 8)` + ); + + scene.physics.fixedTimeStep = originalFTS; + + // Theory: dv_1_60 = F·(1/60)/m = 100/60 ≈ 1.667 + expect(dv_1_60).toBeCloseTo(100 / 60, 2); + // Theory: dv_1_480 = F·(1/480)/m = 100/480 ≈ 0.208 + expect(dv_1_480).toBeCloseTo(100 / 480, 2); + // Ratio must be 8 (PhysX clears force per simulate) + expect(dv_1_60 / dv_1_480).toBeCloseTo(8, 1); + }); + it("maxAngularVelocity", function () { const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); const boxCollider = box.getComponent(DynamicCollider); @@ -398,6 +649,48 @@ describe("DynamicCollider", function () { expect(box.transform.position.y).below(1); }); + it("teleports kinematic target collider on re-enable instead of sweeping from stale native pose", function () { + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(-10, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + boxCollider.useGravity = false; + boxCollider.isKinematic = true; + boxCollider.kinematicTransformSyncMode = DynamicColliderKinematicTransformSyncMode.Target; + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + // @ts-ignore - intentionally observe the native boundary used by Collider sync. + const nativeCollider = boxCollider._nativeCollider; + const originalMove = nativeCollider.move.bind(nativeCollider); + const originalSetWorldTransform = nativeCollider.setWorldTransform.bind(nativeCollider); + let moveCalls = 0; + let setWorldTransformCalls = 0; + nativeCollider.move = (...args: Parameters) => { + moveCalls++; + return originalMove(...args); + }; + nativeCollider.setWorldTransform = (...args: Parameters) => { + setWorldTransformCalls++; + return originalSetWorldTransform(...args); + }; + + try { + box.isActive = false; + box.transform.setPosition(10, 0, 0); + box.isActive = true; + + // @ts-ignore + engine.sceneManager.activeScene.physics._update(1 / 60); + + expect(moveCalls).eq(0); + expect(setWorldTransformCalls).eq(1); + expect(formatValue(box.transform.position.x)).eq(10); + } finally { + nativeCollider.move = originalMove; + nativeCollider.setWorldTransform = originalSetWorldTransform; + } + }); + it("constraints", function () { const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); const boxCollider = box.getComponent(DynamicCollider); @@ -442,6 +735,52 @@ describe("DynamicCollider", function () { ).toBeTruthy(); }); + it("R0: CCD mode survives kinematic toggle (PhysX rejects CCD on kinematic)", function () { + // RED verification for R0 fix: + // PhysX 4.1.1 forbids CCD on kinematic actors. The fix caches the user-intended mode + // and re-applies on kinematic→dynamic. Without the fix, switching to kinematic loses + // the CCD flag and a subsequent dynamic switch does not restore it. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + // @ts-ignore + const physX = boxCollider._nativeCollider._physXPhysics._physX; + const ccdFlag = () => + // @ts-ignore + boxCollider._nativeCollider._pxActor.getRigidBodyFlags(physX.PxRigidBodyFlag.eENABLE_CCD); + + boxCollider.collisionDetectionMode = CollisionDetectionMode.Continuous; + expect(ccdFlag()).toBeTruthy(); + + boxCollider.isKinematic = true; + expect(ccdFlag()).toBeFalsy(); + + boxCollider.isKinematic = false; + expect(ccdFlag()).toBeTruthy(); + expect(boxCollider.collisionDetectionMode).toEqual(CollisionDetectionMode.Continuous); + }); + + it("R0: setCollisionDetectionMode in kinematic state defers application", function () { + // RED verification: while kinematic, the CCD flag should not be touched (PhysX warns). + // User's intent is cached and applied on next dynamic switch. + const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); + const boxCollider = box.getComponent(DynamicCollider); + // @ts-ignore + const physX = boxCollider._nativeCollider._physXPhysics._physX; + const ccdFlag = () => + // @ts-ignore + boxCollider._nativeCollider._pxActor.getRigidBodyFlags(physX.PxRigidBodyFlag.eENABLE_CCD); + + boxCollider.isKinematic = true; + expect(ccdFlag()).toBeFalsy(); + + boxCollider.collisionDetectionMode = CollisionDetectionMode.Continuous; + expect(ccdFlag()).toBeFalsy(); + expect(boxCollider.collisionDetectionMode).toEqual(CollisionDetectionMode.Continuous); + + boxCollider.isKinematic = false; + expect(ccdFlag()).toBeTruthy(); + }); + it("sleep", function () { const box = addBox(new Vector3(2, 2, 2), DynamicCollider, new Vector3(0, 0, 0)); const boxCollider = box.getComponent(DynamicCollider); diff --git a/tests/src/core/physics/HingeJoint.test.ts b/tests/src/core/physics/HingeJoint.test.ts index 6cba89c261..4e55ab8e14 100644 --- a/tests/src/core/physics/HingeJoint.test.ts +++ b/tests/src/core/physics/HingeJoint.test.ts @@ -9,7 +9,7 @@ import { Engine, CapsuleColliderShape } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { Vector3 } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; diff --git a/tests/src/core/physics/Joint.test.ts b/tests/src/core/physics/Joint.test.ts index 40d3ae7760..bfde78a127 100644 --- a/tests/src/core/physics/Joint.test.ts +++ b/tests/src/core/physics/Joint.test.ts @@ -1,5 +1,5 @@ import { FixedJoint, Entity, DynamicCollider, StaticCollider, BoxColliderShape, Engine } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { Vector3 } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { vi, describe, beforeAll, beforeEach, expect, it } from "vitest"; diff --git a/tests/src/core/physics/MeshColliderShape.test.ts b/tests/src/core/physics/MeshColliderShape.test.ts index 3daa37ed94..1b85709d29 100644 --- a/tests/src/core/physics/MeshColliderShape.test.ts +++ b/tests/src/core/physics/MeshColliderShape.test.ts @@ -11,7 +11,7 @@ import { ModelMesh } from "@galacean/engine-core"; import { Vector3 } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { describe, beforeAll, beforeEach, expect, it, vi } from "vitest"; @@ -31,11 +31,7 @@ class CollisionScript extends Script { * @param indices - Optional triangle indices * @returns A ModelMesh with readable data */ -function createModelMesh( - engine: WebGLEngine, - positions: number[], - indices?: number[] -): ModelMesh { +function createModelMesh(engine: WebGLEngine, positions: number[], indices?: number[]): ModelMesh { const mesh = new ModelMesh(engine); const vec3Positions: Vector3[] = []; for (let i = 0; i < positions.length; i += 3) { @@ -107,11 +103,7 @@ describe("MeshColliderShape PhysX", () => { const meshShape = new MeshColliderShape(); const meshMaterial = meshShape.material; // Ground plane at y=0, CCW winding -> normal +Y - const mesh = createModelMesh( - engine, - [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], - [0, 2, 1, 1, 2, 3] - ); + const mesh = createModelMesh(engine, [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], [0, 2, 1, 1, 2, 3]); meshShape.mesh = mesh; groundCollider.addShape(meshShape); @@ -190,6 +182,57 @@ describe("MeshColliderShape PhysX", () => { defaultMaterial?.destroy(); material?.destroy(); }); + + it("R6: cloned MeshColliderShape rebuilds its native PhysX shape", async () => { + // RED verification for R6 fix: + // MeshColliderShape's `_nativeShape` is `@ignoreClone` — without an + // override of `_cloneTo` cooking a fresh shape from cloned vertex/index + // buffers, the cloned entity has no physical surface (sphere falls + // straight through). + const groundEntity = root.createChild("meshGroundForClone"); + groundEntity.transform.setPosition(0, 0, 0); + const groundCollider = groundEntity.addComponent(StaticCollider); + const meshShape = new MeshColliderShape(); + const meshMaterial = meshShape.material; + const mesh = createModelMesh(engine, [-10, 0, -10, 10, 0, -10, -10, 0, 10, 10, 0, 10], [0, 2, 1, 1, 2, 3]); + meshShape.mesh = mesh; + groundCollider.addShape(meshShape); + + const clonedGround = groundEntity.clone(); + // Move the original aside so the cloned ground is the only surface below the sphere. + groundEntity.transform.setPosition(1000, 0, 0); + root.addChild(clonedGround); + clonedGround.transform.setPosition(0, 0, 0); + + const clonedShape = clonedGround.getComponent(StaticCollider).shapes[0] as MeshColliderShape; + // @ts-ignore — inspect that the cloned shape actually has a usable native PhysX handle + expect(clonedShape._nativeShape).not.toBeNull(); + // @ts-ignore + expect(clonedShape._nativeShape._pxShape).toBeDefined(); + + const sphereEntity = root.createChild("sphereForClone"); + sphereEntity.transform.setPosition(0, 2, 0); + const dynamicCollider = sphereEntity.addComponent(DynamicCollider); + const sphereShape = new SphereColliderShape(); + const sphereMaterial = sphereShape.material; + sphereShape.radius = 0.5; + dynamicCollider.addShape(sphereShape); + + for (let i = 0; i < 60; i++) { + physicsScene._update(1 / 60); + } + + // Sphere lands on cloned ground (y > -1), not falls forever (y < -10). + const sphereY = sphereEntity.transform.position.y; + expect(sphereY).toBeGreaterThan(-1); + expect(sphereY).toBeLessThan(2); + + groundEntity.destroy(); + clonedGround.destroy(); + sphereEntity.destroy(); + meshMaterial?.destroy(); + sphereMaterial?.destroy(); + }); }); describe("Convex Mesh (Dynamic)", () => { @@ -265,9 +308,10 @@ describe("MeshColliderShape PhysX", () => { const meshShape = new MeshColliderShape(); const meshMaterial = meshShape.material; meshShape.isConvex = true; - const mesh = createModelMesh(engine, [ - -1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1 - ]); + const mesh = createModelMesh( + engine, + [-1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1] + ); meshShape.mesh = mesh; meshShape.isTrigger = true; triggerCollider.addShape(meshShape); @@ -417,11 +461,7 @@ describe("MeshColliderShape PhysX", () => { staticCollider.addShape(meshShape); // Update mesh - const mesh2 = createModelMesh( - engine, - [0, 0, 0, 2, 0, 0, 0, 2, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], - [0, 1, 2, 3, 4, 5] - ); + const mesh2 = createModelMesh(engine, [0, 0, 0, 2, 0, 0, 0, 2, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], [0, 1, 2, 3, 4, 5]); meshShape.mesh = mesh2; expect(staticCollider.shapes.length).toBe(1); @@ -695,11 +735,7 @@ describe("MeshColliderShape PhysX", () => { expect(meshShape._nativeShape).toBeNull(); // Re-enable with new mesh - const mesh2 = createModelMesh( - engine, - [0, 0, 0, 2, 0, 0, 0, 2, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], - [0, 1, 2, 3, 4, 5] - ); + const mesh2 = createModelMesh(engine, [0, 0, 0, 2, 0, 0, 0, 2, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], [0, 1, 2, 3, 4, 5]); meshShape.mesh = mesh2; // @ts-ignore expect(meshShape._nativeShape).not.toBeNull(); diff --git a/tests/src/core/physics/PhysicsMaterial.test.ts b/tests/src/core/physics/PhysicsMaterial.test.ts index 676f917fe3..7bfd0e0e88 100644 --- a/tests/src/core/physics/PhysicsMaterial.test.ts +++ b/tests/src/core/physics/PhysicsMaterial.test.ts @@ -8,7 +8,7 @@ import { StaticCollider, PlaneColliderShape } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { Vector3 } from "@galacean/engine-math"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; @@ -79,6 +79,56 @@ describe("PhysicsMaterial", () => { expect(formatValue(boxEntity2.transform.position.y)).eq(0); }); + it("cloned collider shape material keeps native values", () => { + const scene = engine.sceneManager.activeScene; + const originalGravity = scene.physics.gravity.clone(); + const originalFixedTimeStep = scene.physics.fixedTimeStep; + scene.physics.gravity = new Vector3(0, 0, 0); + scene.physics.fixedTimeStep = 1 / 60; + + try { + const wallEntity = addBox(new Vector3(1, 8, 8), StaticCollider, new Vector3(0, 0, 0)); + const wallMaterial = wallEntity.getComponent(StaticCollider).shapes[0].material; + wallMaterial.bounciness = 1; + wallMaterial.dynamicFriction = 0; + wallMaterial.staticFriction = 0; + wallMaterial.bounceCombine = PhysicsMaterialCombineMode.Multiply; + wallMaterial.frictionCombine = PhysicsMaterialCombineMode.Multiply; + + const sourceEntity = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(-3, 0, 0)); + const sourceCollider = sourceEntity.getComponent(DynamicCollider); + sourceCollider.linearDamping = 0; + sourceCollider.angularDamping = 0; + sourceCollider.automaticCenterOfMass = false; + sourceCollider.automaticInertiaTensor = false; + + const sourceMaterial = sourceCollider.shapes[0].material; + sourceMaterial.bounciness = 1; + sourceMaterial.dynamicFriction = 0; + sourceMaterial.staticFriction = 0; + sourceMaterial.bounceCombine = PhysicsMaterialCombineMode.Multiply; + sourceMaterial.frictionCombine = PhysicsMaterialCombineMode.Multiply; + + const cloneEntity = sourceEntity.clone(); + sourceEntity.destroy(); + rootEntity.addChild(cloneEntity); + cloneEntity.transform.setPosition(-3, 0, 0); + + const cloneCollider = cloneEntity.getComponent(DynamicCollider); + cloneCollider.linearVelocity = new Vector3(10, 0, 0); + + for (let i = 0; i < 40; i++) { + // @ts-ignore + scene.physics._update(scene.physics.fixedTimeStep); + } + + expect(cloneCollider.linearVelocity.x).lessThan(-1); + } finally { + scene.physics.gravity = originalGravity; + scene.physics.fixedTimeStep = originalFixedTimeStep; + } + }); + it("bounceCombine Average", () => { const boxEntity = addBox(new Vector3(1, 1, 1), DynamicCollider, new Vector3(0, 5, 0)); const ground = addPlane(0, -0.5, 0); diff --git a/tests/src/core/physics/PhysicsScene.test.ts b/tests/src/core/physics/PhysicsScene.test.ts index d514abf337..9f258a1e2d 100644 --- a/tests/src/core/physics/PhysicsScene.test.ts +++ b/tests/src/core/physics/PhysicsScene.test.ts @@ -12,13 +12,12 @@ import { Scene, Script, SphereColliderShape, - StaticCollider, - OverlapHitResult + StaticCollider } from "@galacean/engine-core"; import { Ray, Vector3, Quaternion } from "@galacean/engine-math"; import { LitePhysics } from "@galacean/engine-physics-lite"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { vi, describe, beforeAll, expect, it, afterEach } from "vitest"; class CollisionTestScript extends Script { @@ -74,12 +73,44 @@ class CollisionTestScript extends Script { } } +class CollisionDemandScript extends Script { + onCollisionEnter(): void {} +} + +class TriggerDemandScript extends Script { + onTriggerEnter(): void {} +} + function updatePhysics(physics) { for (let i = 0; i < 5; ++i) { physics._update(8); } } +function watchNativeContactEventDemand(physicsScene: PhysicsScene) { + const nativeScene = (physicsScene as any)._nativePhysicsScene; + const original = nativeScene.setContactEventEnabled; + const calls: boolean[] = []; + nativeScene.setContactEventEnabled = (enabled: boolean) => { + calls.push(enabled); + original?.call(nativeScene, enabled); + }; + return { + calls, + restore() { + if (original) { + nativeScene.setContactEventEnabled = original; + } else { + delete nativeScene.setContactEventEnabled; + } + } + }; +} + +function getLastContactEventDemandCall(calls: boolean[]): boolean { + return calls[calls.length - 1]; +} + function resetSpy() { // reset spy on collision test script. CollisionTestScript.prototype.onCollisionEnter = vi.fn(CollisionTestScript.prototype.onCollisionEnter); @@ -422,6 +453,66 @@ describe("Physics Test", () => { expect(enginePhysX.sceneManager.scenes[0].physics.fixedTimeStep).to.eq(fixedTimeStep); }); + it("auto-disables native contact events when no active collision callback exists", () => { + const scene = enginePhysX.sceneManager.activeScene; + const physicsScene = scene.physics; + const root = scene.createRootEntity("contact-demand-disabled"); + const entity = root.createChild("body"); + const collider = entity.addComponent(StaticCollider); + collider.addShape(new BoxColliderShape()); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + root.destroy(); + } + }); + + it("auto-enables native contact events only while an active collision callback exists", () => { + const scene = enginePhysX.sceneManager.activeScene; + const physicsScene = scene.physics; + const root = scene.createRootEntity("contact-demand-enabled"); + const entity = root.createChild("body"); + const collider = entity.addComponent(StaticCollider); + collider.addShape(new BoxColliderShape()); + const script = entity.addComponent(CollisionDemandScript); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(true); + + script.enabled = false; + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + root.destroy(); + } + }); + + it("keeps native contact events disabled when only trigger callbacks exist", () => { + const scene = enginePhysX.sceneManager.activeScene; + const physicsScene = scene.physics; + const root = scene.createRootEntity("contact-demand-trigger-only"); + const entity = root.createChild("body"); + const collider = entity.addComponent(StaticCollider); + collider.addShape(new BoxColliderShape()); + entity.addComponent(TriggerDemandScript); + const contactEventDemand = watchNativeContactEventDemand(physicsScene); + + try { + physicsScene._update(physicsScene.fixedTimeStep); + expect(getLastContactEventDemandCall(contactEventDemand.calls)).to.eq(false); + } finally { + contactEventDemand.restore(); + root.destroy(); + } + }); + it("raycast", () => { const scene = enginePhysX.sceneManager.activeScene; const physicsScene = scene.physics; @@ -503,6 +594,7 @@ describe("Physics Test", () => { expect(physicsScene.raycast(ray, Number.MAX_VALUE, Layer.Everything, outHitResult)).to.eq(false); const rootEntityCharacter = root.createChild("root_character"); + rootEntityCharacter.layer = Layer.Layer3; rootEntityCharacter.transform.position = new Vector3(0, 0, 0); const characterController = rootEntityCharacter.addComponent(CharacterController); @@ -536,286 +628,6 @@ describe("Physics Test", () => { root.destroy(); }); - it("raycast skips initial overlap when ray origin is inside a collider", () => { - const scene = enginePhysX.sceneManager.activeScene; - const physicsScene = scene.physics; - const root = scene.createRootEntity("root"); - - // Box at origin, encompassing the ray origin - const insideBox = root.createChild("inside_box"); - insideBox.transform.position = new Vector3(0, 0, 0); - const insideCollider = insideBox.addComponent(StaticCollider); - const insideShape = new BoxColliderShape(); - insideShape.size = new Vector3(2, 2, 2); - insideCollider.addShape(insideShape); - - // Box further along the ray direction - const farBox = root.createChild("far_box"); - farBox.transform.position = new Vector3(5, 0, 0); - const farCollider = farBox.addComponent(StaticCollider); - const farShape = new BoxColliderShape(); - farShape.size = new Vector3(2, 2, 2); - farCollider.addShape(farShape); - - // Cast ray from origin (inside `insideBox`) outward - const hit = new HitResult(); - const ray = new Ray(new Vector3(0, 0, 0), new Vector3(1, 0, 0)); - const ok = physicsScene.raycast(ray, 100, hit); - - expect(ok).to.eq(true); - // Should hit the far box, NOT the box at origin (initial overlap is skipped) - expect(hit.entity).to.eq(farBox); - expect(hit.distance).to.be.greaterThan(0); - - root.destroy(); - }); - - it("boxCast skips initial overlap and hits far collider beyond", () => { - const scene = enginePhysX.sceneManager.activeScene; - const physicsScene = scene.physics; - const root = scene.createRootEntity("boxcast_initial_overlap_root"); - - const insideBox = root.createChild("inside_box"); - insideBox.transform.position = new Vector3(0, 0, 0); - const insideCol = insideBox.addComponent(StaticCollider); - const insideShape = new BoxColliderShape(); - insideShape.size = new Vector3(2, 2, 2); - insideCol.addShape(insideShape); - - const farBox = root.createChild("far_box"); - farBox.transform.position = new Vector3(5, 0, 0); - const farCol = farBox.addComponent(StaticCollider); - const farShape = new BoxColliderShape(); - farShape.size = new Vector3(2, 2, 2); - farCol.addShape(farShape); - - const halfExtents = new Vector3(0.5, 0.5, 0.5); - const direction = new Vector3(1, 0, 0); - const hit = new HitResult(); - // Sweep origin (0,0,0) is inside insideBox; should skip and hit farBox - const ok = physicsScene.boxCast(new Vector3(0, 0, 0), halfExtents, direction, 100, hit); - - expect(ok).to.eq(true); - expect(hit.entity).to.eq(farBox); - expect(hit.distance).to.be.greaterThan(0); - - root.destroy(); - }); - - it("sphereCast skips initial overlap and hits far collider beyond", () => { - const scene = enginePhysX.sceneManager.activeScene; - const physicsScene = scene.physics; - const root = scene.createRootEntity("spherecast_initial_overlap_root"); - - const insideBox = root.createChild("inside_box"); - insideBox.transform.position = new Vector3(0, 0, 0); - const insideCol = insideBox.addComponent(StaticCollider); - const insideShape = new BoxColliderShape(); - insideShape.size = new Vector3(2, 2, 2); - insideCol.addShape(insideShape); - - const farBox = root.createChild("far_box"); - farBox.transform.position = new Vector3(5, 0, 0); - const farCol = farBox.addComponent(StaticCollider); - const farShape = new BoxColliderShape(); - farShape.size = new Vector3(2, 2, 2); - farCol.addShape(farShape); - - const direction = new Vector3(1, 0, 0); - const hit = new HitResult(); - const ok = physicsScene.sphereCast(new Vector3(0, 0, 0), 0.4, direction, 100, hit); - - expect(ok).to.eq(true); - expect(hit.entity).to.eq(farBox); - expect(hit.distance).to.be.greaterThan(0); - - root.destroy(); - }); - - it("capsuleCast skips initial overlap and hits far collider beyond", () => { - const scene = enginePhysX.sceneManager.activeScene; - const physicsScene = scene.physics; - const root = scene.createRootEntity("capsulecast_initial_overlap_root"); - - const insideBox = root.createChild("inside_box"); - insideBox.transform.position = new Vector3(0, 0, 0); - const insideCol = insideBox.addComponent(StaticCollider); - const insideShape = new BoxColliderShape(); - insideShape.size = new Vector3(2, 2, 2); - insideCol.addShape(insideShape); - - const farBox = root.createChild("far_box"); - farBox.transform.position = new Vector3(5, 0, 0); - const farCol = farBox.addComponent(StaticCollider); - const farShape = new BoxColliderShape(); - farShape.size = new Vector3(2, 2, 2); - farCol.addShape(farShape); - - const direction = new Vector3(1, 0, 0); - const hit = new HitResult(); - const ok = physicsScene.capsuleCast(new Vector3(0, 0, 0), 0.3, 0.5, direction, 100, hit); - - expect(ok).to.eq(true); - expect(hit.entity).to.eq(farBox); - expect(hit.distance).to.be.greaterThan(0); - - root.destroy(); - }); - - it("raycast nested inside another raycast's onRaycast keeps stack ordering", () => { - const scene = enginePhysX.sceneManager.activeScene; - const root = scene.createRootEntity("nested_raycast_root"); - // Native PhysX scene exposes the (ray, distance, onRaycast, hit) signature - // that takes a per-call filter; the persistent-callback stack pattern is - // verified through this layer. - const nativeScene = (scene.physics as any)._nativePhysicsScene; - - const boxA = root.createChild("box_a"); - boxA.transform.position = new Vector3(2, 0, 0); - const colA = boxA.addComponent(StaticCollider); - const shapeA = new BoxColliderShape(); - shapeA.size = new Vector3(1, 1, 1); - colA.addShape(shapeA); - - const boxB = root.createChild("box_b"); - boxB.transform.position = new Vector3(0, 2, 0); - const colB = boxB.addComponent(StaticCollider); - const shapeB = new BoxColliderShape(); - shapeB.size = new Vector3(1, 1, 1); - colB.addShape(shapeB); - - const seenInOuter: number[] = []; - const seenInInner: number[] = []; - - const outerRay = new Ray(new Vector3(-5, 0, 0), new Vector3(1, 0, 0)); - nativeScene.raycast(outerRay, 100, (uuid: number) => { - seenInOuter.push(uuid); - // Nested raycast inside the outer's filter callback. If the stack got - // mixed up, the inner ray's preFilter would dispatch to the outer - // recorder (or vice versa). - const innerRay = new Ray(new Vector3(0, -5, 0), new Vector3(0, 1, 0)); - nativeScene.raycast(innerRay, 100, (innerUuid: number) => { - seenInInner.push(innerUuid); - return true; - }); - return true; - }); - - // The outer ray (along +X from -5,0,0) cannot intersect boxB at (0,2,0), - // so its preFilter must never see shapeB. Conversely, the inner ray - // (along +Y from 0,-5,0) cannot intersect boxA at (2,0,0), so its - // preFilter must never see shapeA. Stack mixing would violate either. - expect(seenInOuter).to.not.include(shapeB.id); - expect(seenInInner).to.not.include(shapeA.id); - // Both filters must have run — otherwise the assertions above are vacuous. - expect(seenInOuter.length).to.be.greaterThan(0); - expect(seenInInner.length).to.be.greaterThan(0); - - root.destroy(); - }); - - it("sweep nested inside raycast's onRaycast uses independent filter stacks", () => { - const scene = enginePhysX.sceneManager.activeScene; - const root = scene.createRootEntity("nested_mixed_root"); - const nativeScene = (scene.physics as any)._nativePhysicsScene; - - const boxA = root.createChild("box_a"); - boxA.transform.position = new Vector3(3, 0, 0); - const colA = boxA.addComponent(StaticCollider); - const shapeA = new BoxColliderShape(); - shapeA.size = new Vector3(1, 1, 1); - colA.addShape(shapeA); - - let outerCalls = 0; - let innerSweepCalls = 0; - const innerSweepUuids: number[] = []; - - const outerRay = new Ray(new Vector3(-5, 0, 0), new Vector3(1, 0, 0)); - const outerHitFn = (uuid: number, distance: number, _p: Vector3, _n: Vector3) => { - // The outer raycast must successfully report a hit on shapeA's UUID. - expect(uuid).to.eq(shapeA.id); - expect(distance).to.be.greaterThan(0); - }; - const result = nativeScene.raycast( - outerRay, - 100, - (uuid: number) => { - outerCalls++; - // Nested boxCast (sweep) inside the raycast filter — uses a different - // persistent callback / stack on the PhysX side. The two stacks must - // not interfere. - const sweepCenter = new Vector3(3, 0, 0); - const halfExtents = new Vector3(0.5, 0.5, 0.5); - const direction = new Vector3(0, 1, 0); - const orientation = new Quaternion(0, 0, 0, 1); - nativeScene.boxCast( - sweepCenter, - orientation, - halfExtents, - direction, - 100, - (sweepUuid: number) => { - innerSweepCalls++; - innerSweepUuids.push(sweepUuid); - return false; // skip everything in inner sweep - } - ); - return uuid === shapeA.id; - }, - outerHitFn - ); - - // Outer raycast must succeed despite the nested sweep with its own filter. - expect(result).to.eq(true); - expect(outerCalls).to.be.greaterThan(0); - // Nested sweep had to actually run at least once for this test to be meaningful. - expect(innerSweepCalls).to.be.greaterThan(0); - - root.destroy(); - }); - - it("raycast callback throwing leaves the filter stack clean for subsequent calls", () => { - const scene = enginePhysX.sceneManager.activeScene; - const root = scene.createRootEntity("throw_recovery_root"); - const nativeScene = (scene.physics as any)._nativePhysicsScene; - - const box = root.createChild("box"); - box.transform.position = new Vector3(2, 0, 0); - const col = box.addComponent(StaticCollider); - const shape = new BoxColliderShape(); - shape.size = new Vector3(1, 1, 1); - col.addShape(shape); - - const ray = new Ray(new Vector3(-5, 0, 0), new Vector3(1, 0, 0)); - - expect(() => { - nativeScene.raycast(ray, 100, () => { - throw new Error("intentional in test"); - }); - }).to.throw("intentional in test"); - - // Stack must be clean — subsequent raycast must work and the shared - // persistent callback must dispatch to the new filter, not a stale one. - let secondCalled = false; - let observedUuid = -1; - const ok = nativeScene.raycast( - ray, - 100, - (uuid: number) => { - secondCalled = true; - observedUuid = uuid; - return true; - }, - (_uuid: number, _distance: number, _p: Vector3, _n: Vector3) => {} - ); - - expect(secondCalled).to.eq(true); - expect(ok).to.eq(true); - expect(observedUuid).to.eq(shape.id); - - root.destroy(); - }); - it("boxCast", () => { const scene = enginePhysX.sceneManager.activeScene; const physicsScene = scene.physics; @@ -833,7 +645,7 @@ describe("Physics Test", () => { const halfExtents = new Vector3(0.5, 0.5, 0.5); const direction = new Vector3(0, 1, 0); const orientation = new Quaternion(); - expect(physicsScene.boxCast(center, halfExtents, direction, orientation)).to.eq(false); + expect(physicsScene.boxCast(center, halfExtents, direction)).to.eq(false); // Test boxCast with hit direction.set(-1, -1, -1); @@ -1787,14 +1599,16 @@ describe("Physics Test", () => { collisionTestScript.useLite = false; // Test that collision works correctly, A is dynamic and kinematic, B is static. + // SceneDesc.staticKineFilteringMode = eKEEP + Collider.move() routing kinematic + // to setKinematicTarget make static-kinematic pairs generate contact events. resetSpy(); setColliderProps(entity1, true, false, true); setColliderProps(entity2, false, false, false); updatePhysics(physicsMgr); - expect(collisionTestScript.onCollisionEnter).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionStay).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionExit).not.toHaveBeenCalled(); + expect(collisionTestScript.onCollisionEnter).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionStay).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionExit).toHaveBeenCalled(); expect(collisionTestScript.onTriggerEnter).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerStay).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerExit).not.toHaveBeenCalled(); @@ -1907,14 +1721,16 @@ describe("Physics Test", () => { collisionTestScript.useLite = false; // Test that collision works correctly, both A,B are dynamic, kinematic. + // SceneDesc.kineKineFilteringMode = eKEEP + Collider.move() routing kinematic + // to setKinematicTarget make kine-kine pairs generate contact events. resetSpy(); setColliderProps(entity1, true, false, true); setColliderProps(entity2, true, false, true); updatePhysics(physicsMgr); - expect(collisionTestScript.onCollisionEnter).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionStay).not.toHaveBeenCalled(); - expect(collisionTestScript.onCollisionExit).not.toHaveBeenCalled(); + expect(collisionTestScript.onCollisionEnter).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionStay).toHaveBeenCalled(); + expect(collisionTestScript.onCollisionExit).toHaveBeenCalled(); expect(collisionTestScript.onTriggerEnter).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerStay).not.toHaveBeenCalled(); expect(collisionTestScript.onTriggerExit).not.toHaveBeenCalled(); diff --git a/tests/src/core/physics/SpringJoint.test.ts b/tests/src/core/physics/SpringJoint.test.ts index ebc62d0287..768a200382 100644 --- a/tests/src/core/physics/SpringJoint.test.ts +++ b/tests/src/core/physics/SpringJoint.test.ts @@ -1,5 +1,5 @@ import { Entity, DynamicCollider, StaticCollider, BoxColliderShape, Engine, SpringJoint } from "@galacean/engine-core"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { Vector3 } from "@galacean/engine-math"; import { PhysXPhysics } from "@galacean/engine-physics-physx"; import { describe, beforeAll, beforeEach, expect, it } from "vitest"; diff --git a/tests/src/loader/GLTFLoader.test.ts b/tests/src/loader/GLTFLoader.test.ts index 6f3eaf0b17..164dd788aa 100644 --- a/tests/src/loader/GLTFLoader.test.ts +++ b/tests/src/loader/GLTFLoader.test.ts @@ -26,7 +26,7 @@ import { registerGLTFParser } from "@galacean/engine-loader"; import { Color } from "@galacean/engine-math"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { describe, beforeAll, afterAll, expect, it } from "vitest"; let engine: WebGLEngine; @@ -40,7 +40,7 @@ beforeAll(async function () { class GLTFCustomJSONParser extends GLTFParser { parse(context: GLTFParserContext) { if (context.glTFResource.url.endsWith("testSkinRoot.gltf")) { - context.buffers = [new ArrayBuffer(192)]; + context.buffers = [new ArrayBuffer(128)]; return Promise.resolve({ asset: { version: "2.0" @@ -53,7 +53,8 @@ beforeAll(async function () { ], nodes: [ { - name: "Character_Man" + name: "Character_Man", + skin: 0 }, { name: "mixamorig:Hips", @@ -66,8 +67,7 @@ beforeAll(async function () { skins: [ { inverseBindMatrices: 0, - // Joints span both top-level scene roots: Character_Man (0) and Hips (1)/Spine (2). - joints: [0, 1, 2] + joints: [1, 2] } ], accessors: [ @@ -75,7 +75,7 @@ beforeAll(async function () { bufferView: 0, byteOffset: 0, componentType: 5126, - count: 3, + count: 2, type: "MAT4" } ], @@ -83,19 +83,26 @@ beforeAll(async function () { { buffer: 0, byteOffset: 0, - byteLength: 192 + byteLength: 128 } ], buffers: [ { - byteLength: 192 + byteLength: 128 } ] }); } - if (context.glTFResource.url.endsWith("testSingleSkeleton.gltf")) { - context.buffers = [new ArrayBuffer(128)]; + if (context.glTFResource.url.endsWith("testSkinRootBounds.gltf")) { + const buffer = new ArrayBuffer(152); + const floats = new Float32Array(buffer); + // Inverse bind matrices for Hips and Spine. Their bind pose world x is + // Character_Group(3) + Hips(10), so inverse bind translates by -13. + floats.set([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -13, 0, 0, 1], 0); + floats.set([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -13, 0, 0, 1], 16); + floats.set([9, -1, -1, 11, 1, 1], 32); + context.buffers = [buffer]; return Promise.resolve({ asset: { version: "2.0" @@ -103,27 +110,45 @@ beforeAll(async function () { scene: 0, scenes: [ { - // Two top-level roots: a character skeleton and an unrelated sibling (e.g., a light). - nodes: [0, 2] + nodes: [0] } ], nodes: [ { - name: "Character_Root", - children: [1] + name: "Character_Group", + translation: [3, 0, 0], + children: [1, 2] }, { - name: "mixamorig:Hips" + name: "Character_Man", + mesh: 0, + skin: 0 }, { - name: "Light" + name: "mixamorig:Hips", + translation: [10, 0, 0], + children: [3] + }, + { + name: "mixamorig:Spine" } ], skins: [ { inverseBindMatrices: 0, - // All joints converge to a single top-level root (Character_Root). - joints: [0, 1] + joints: [2, 3] + } + ], + meshes: [ + { + primitives: [ + { + attributes: { + POSITION: 1 + }, + mode: 4 + } + ] } ], accessors: [ @@ -133,6 +158,15 @@ beforeAll(async function () { componentType: 5126, count: 2, type: "MAT4" + }, + { + bufferView: 1, + byteOffset: 0, + componentType: 5126, + count: 2, + type: "VEC3", + min: [9, -1, -1], + max: [11, 1, 1] } ], bufferViews: [ @@ -140,11 +174,16 @@ beforeAll(async function () { buffer: 0, byteOffset: 0, byteLength: 128 + }, + { + buffer: 0, + byteOffset: 128, + byteLength: 24 } ], buffers: [ { - byteLength: 128 + byteLength: 152 } ] }); @@ -504,7 +543,7 @@ beforeAll(async function () { afterAll(() => { @registerGLTFParser(GLTFParserType.Schema) - class test extends GLTFSchemaParser { } + class test extends GLTFSchemaParser {} }); describe("glTF Loader test", function () { @@ -665,19 +704,21 @@ describe("glTF scene root structure", function () { expect(skins[0].rootBone).to.equal(defaultSceneRoot); }); - it("Multi-root scenes whose joints converge to a single top-level root should not use the scene wrapper", async () => { + it("Skinned mesh bounds should stay in rootBone space when inferred rootBone is outside joints", async () => { const glTFResource: GLTFResource = await engine.resourceManager.load({ type: AssetType.GLTF, - url: "mock/path/testSingleSkeleton.gltf" + url: "mock/path/testSkinRootBounds.gltf" }); const { defaultSceneRoot, skins } = glTFResource; - - expect(defaultSceneRoot.name).to.equal("GLTF_ROOT"); - // Scene has two top-level roots, but all joints converge to "Character_Root". - expect(defaultSceneRoot.children.length).to.equal(2); - expect(skins[0].rootBone).to.not.equal(defaultSceneRoot); - // rootBone should be inside the Character_Root subtree (LCA = Character_Root). - expect(skins[0].rootBone.name).to.equal("Character_Root"); + const characterGroup = defaultSceneRoot.children[0]; + const characterMesh = characterGroup.children[0]; + const renderer = characterMesh.getComponent(SkinnedMeshRenderer); + + expect(skins[0].rootBone).to.equal(characterGroup); + expect(renderer.localBounds.min.x).to.be.closeTo(6, 1e-5); + expect(renderer.localBounds.max.x).to.be.closeTo(8, 1e-5); + expect(renderer.bounds.min.x).to.be.closeTo(9, 1e-5); + expect(renderer.bounds.max.x).to.be.closeTo(11, 1e-5); }); }); diff --git a/tests/src/loader/PrefabResource.test.ts b/tests/src/loader/PrefabResource.test.ts index cc0636e1e9..24088c2932 100644 --- a/tests/src/loader/PrefabResource.test.ts +++ b/tests/src/loader/PrefabResource.test.ts @@ -546,7 +546,15 @@ describe("Prefab instance overrides", () => { instance: { asset: 0, overrides: { - removedComponents: [{ path: [], selectors: [{ type: "MeshRenderer", index: 0 }, { type: "MeshRenderer", index: 2 }] }] + removedComponents: [ + { + path: [], + selectors: [ + { type: "MeshRenderer", index: 0 }, + { type: "MeshRenderer", index: 2 } + ] + } + ] } } } @@ -760,10 +768,7 @@ describe("Cross-prefab $component ref", () => { const outerPrefabData: PrefabFile = { version: "2.0", refs: [{ url: "deep-entity-nested.prefab" }], - entities: [ - { name: "outerRoot", children: [1], components: [0] }, - { instance: { asset: 0 } } - ], + entities: [{ name: "outerRoot", children: [1], components: [0] }, { instance: { asset: 0 } }], components: [ { type: "EntityRefScript", @@ -809,10 +814,7 @@ describe("Cross-prefab $component ref", () => { const outerPrefabData: PrefabFile = { version: "2.0", refs: [{ url: "deep-comp-nested.prefab" }], - entities: [ - { name: "outerRoot", children: [1], components: [0] }, - { instance: { asset: 0 } } - ], + entities: [{ name: "outerRoot", children: [1], components: [0] }, { instance: { asset: 0 } }], components: [ { type: "DiceScript", diff --git a/tests/src/loader/RenderTargetLoader.test.ts b/tests/src/loader/RenderTargetLoader.test.ts index 543f33e91c..805c791392 100644 --- a/tests/src/loader/RenderTargetLoader.test.ts +++ b/tests/src/loader/RenderTargetLoader.test.ts @@ -67,14 +67,16 @@ describe("RenderTargetLoader", () => { depthFormat: TextureFormat.Depth, antiAliasing: 1, autoGenerateMipmaps: false, - colorTextures: [{ - mipmap: false, - isSRGBColorSpace: true, - filterMode: 1, - wrapModeU: 1, - wrapModeV: 1, - anisoLevel: 4 - }] + colorTextures: [ + { + mipmap: false, + isSRGBColorSpace: true, + filterMode: 1, + wrapModeU: 1, + wrapModeV: 1, + anisoLevel: 4 + } + ] }); const rt = await engine.resourceManager.load({ url, type: AssetType.RenderTarget });