From 432cd270852967cb64851efa773917207bcb623a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Wed, 15 Jul 2026 19:57:48 +0800 Subject: [PATCH 1/8] feat(spine): render SpineAnimationRenderer directly inside UICanvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpineAnimationRenderer now works in both spaces with no separate component: in world space it renders through the camera pipeline as before; placed under a root UICanvas it is hosted by the canvas — collected and ordered with the other UI elements (all render modes incl. overlay), faded by UIGroup alpha, clipped by RectMask2D, and optionally hit-testable against its skeleton bounds. resource.instantiate() therefore works in both spaces. Core gains a canvas-hosting protocol so any renderer package can opt in without depending on the ui package: - IUIElement/IUIGroupAble/IUIHostedRenderer/IUIGroup interfaces + the RootCanvasModifyFlags/GroupModifyFlags/EntityUIModifyFlags enums move to core (ui re-exports them); IUICanvas widened with the hosting surface - UIElementUtils: the root-canvas/group registration + parent-chain listener machinery, extracted from ui's Utils (ui delegates); hierarchy version counter and Entity._updateUIHierarchyVersion move from the ui Entity mixin into core ui package: - UICanvas._walk collects any Renderer implementing IUIHostedRenderer and wires its canvas/group/rect-mask state exactly like a UIRenderer; the canvas applies rect-clip shader state for hosted renderers (UIRenderer keeps doing its own via the shared Utils helper) - rect-clip state computation extracted to Utils for both consumer kinds spine package: - SpineAnimationRenderer implements IUIHostedRenderer: registration switches between componentsManager renderers and canvas hosting on enable/reparent (whole-parent-chain listeners re-home it live), dual-path _render (canvas elements vs pipeline push), group alpha folds into vertex colors through the new ISpineRenderTarget.globalAlpha (multiplied before premultiplication so PMA light/dark colors follow; both 3.8/4.2 generators), and the RENDERER_UI_RECT_CLIP shader macro toggles with hosting - spine shader: macro-gated rect clip deriving world position from renderer_ModelMat (spine vertices are entity-local, unlike pre-transformed UI chunk vertices), PMA-aware clip fade, hard clip on the rect factor (additive slots legitimately emit zero-alpha fragments), and overlay-pass sRGB output correction e2e: serve spine packages from source in the case dev server — their ES5 dist subclasses of native @esotericsoftware/spine-core classes throw "Class constructor cannot be invoked without 'new'", so spine cases could not run at all; existing spineboy/tintBlack baselines pass unchanged. tests: 14-case hosted-behavior suite (incl. live re-homing on reparent); e2e: spine-ui-canvas and spine-ui-rect-mask with baselines. Co-Authored-By: Claude Fable 5 --- e2e/.dev/vite.config.js | 11 +- e2e/case/spine-ui-canvas.ts | 58 ++++ e2e/case/spine-ui-rect-mask.ts | 55 +++ e2e/config.ts | 12 + .../originImage/Spine_spine-ui-canvas.jpg | 3 + .../originImage/Spine_spine-ui-rect-mask.jpg | 3 + e2e/package.json | 3 +- packages/core/src/Entity.ts | 14 + packages/core/src/index.ts | 1 + .../core/src/shaderlib/extra/spine.fs.glsl | 43 +++ .../core/src/shaderlib/extra/spine.vs.glsl | 11 + packages/core/src/ui/IUICanvas.ts | 8 +- packages/core/src/ui/IUIElement.ts | 110 ++++++ packages/core/src/ui/UIElementUtils.ts | 172 ++++++++++ packages/core/src/ui/index.ts | 4 + packages/spine-core-3.8/src/SpineGenerator.ts | 15 +- packages/spine-core-4.2/src/SpineGenerator.ts | 15 +- packages/spine/src/index.ts | 1 + .../src/renderer/SpineAnimationRenderer.ts | 310 ++++++++++++++++- .../spine/src/runtime/ISpineRenderTarget.ts | 12 +- packages/ui/src/Utils.ts | 286 +++++++++------- packages/ui/src/component/UICanvas.ts | 56 ++-- packages/ui/src/component/UIGroup.ts | 19 +- packages/ui/src/component/UIRenderer.ts | 137 +------- .../ui/src/component/advanced/RectMask2D.ts | 5 +- packages/ui/src/component/index.ts | 1 + packages/ui/src/index.ts | 13 - pnpm-lock.yaml | 3 + .../spine/SpineAnimationRendererUI.test.ts | 313 ++++++++++++++++++ 29 files changed, 1372 insertions(+), 322 deletions(-) create mode 100644 e2e/case/spine-ui-canvas.ts create mode 100644 e2e/case/spine-ui-rect-mask.ts create mode 100644 e2e/fixtures/originImage/Spine_spine-ui-canvas.jpg create mode 100644 e2e/fixtures/originImage/Spine_spine-ui-rect-mask.jpg create mode 100644 packages/core/src/ui/IUIElement.ts create mode 100644 packages/core/src/ui/UIElementUtils.ts create mode 100644 packages/core/src/ui/index.ts create mode 100644 tests/src/spine/SpineAnimationRendererUI.test.ts diff --git a/e2e/.dev/vite.config.js b/e2e/.dev/vite.config.js index 498a9a0244..779fb14410 100644 --- a/e2e/.dev/vite.config.js +++ b/e2e/.dev/vite.config.js @@ -50,7 +50,16 @@ module.exports = { strictPort: true }, resolve: { - dedupe: ["@galacean/engine"] + dedupe: ["@galacean/engine"], + alias: { + // Serve the spine packages from source: their dist is downleveled to ES5, and an ES5 + // subclass extending the external @esotericsoftware/spine-core native classes throws + // "Class constructor cannot be invoked without 'new'". + "@galacean/engine-spine-core-4.2": path.resolve(__dirname, "../../packages/spine-core-4.2/src/index.ts"), + "@galacean/engine-spine-core-3.8": path.resolve(__dirname, "../../packages/spine-core-3.8/src/index.ts"), + "@galacean/engine-spine-ui": path.resolve(__dirname, "../../packages/spine-ui/src/index.ts"), + "@galacean/engine-spine": path.resolve(__dirname, "../../packages/spine/src/index.ts") + } }, optimizeDeps: { exclude: [ diff --git a/e2e/case/spine-ui-canvas.ts b/e2e/case/spine-ui-canvas.ts new file mode 100644 index 0000000000..6a7cd5035f --- /dev/null +++ b/e2e/case/spine-ui-canvas.ts @@ -0,0 +1,58 @@ +/** + * @title Spine UI Canvas + * @category Spine + */ +import { Camera, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; +import "@galacean/engine-spine-core-4.2"; +import { CanvasRenderMode, UICanvas, UIGroup } from "@galacean/engine-ui"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +Logger.enable(); + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity(); + + const cameraEntity = root.createChild("camera"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 20); + cameraEntity.transform.lookAt(new Vector3(0, 0, 0)); + + engine.resourceManager + .load({ + urls: ["/spineboy.json", "/spineboy.atlas", "/spineboy.png"], + type: "Spine" + }) + .then((resource: any) => { + // Left: world space — the renderer joins the camera pipeline. + const worldEntity = resource.instantiate(); + worldEntity.transform.setPosition(-5, -3.2, 0); + root.addChild(worldEntity); + worldEntity.getComponent(SpineAnimationRenderer).state.setAnimation(0, "idle", true); + + // Middle & right: the same SpineAnimationRenderer hosted by a world-space UICanvas; + // the right one is faded by a UIGroup. + const canvasEntity = root.createChild("canvas"); + const canvas = canvasEntity.addComponent(UICanvas); + canvas.renderMode = CanvasRenderMode.WorldSpace; + + const uiEntity = resource.instantiate(); + uiEntity.transform.setPosition(0, -3.2, 0); + canvasEntity.addChild(uiEntity); + uiEntity.getComponent(SpineAnimationRenderer).state.setAnimation(0, "idle", true); + + const groupEntity = canvasEntity.createChild("group"); + const group = groupEntity.addComponent(UIGroup); + group.alpha = 0.5; + const fadedEntity = resource.instantiate(); + fadedEntity.transform.setPosition(5, -3.2, 0); + groupEntity.addChild(fadedEntity); + fadedEntity.getComponent(SpineAnimationRenderer).state.setAnimation(0, "idle", true); + + updateForE2E(engine); + initScreenshot(engine, camera); + }) + .catch((e) => console.error("CASE ERROR", e)); +}); diff --git a/e2e/case/spine-ui-rect-mask.ts b/e2e/case/spine-ui-rect-mask.ts new file mode 100644 index 0000000000..0581876c9f --- /dev/null +++ b/e2e/case/spine-ui-rect-mask.ts @@ -0,0 +1,55 @@ +/** + * @title Spine UI Rect Mask + * @category Spine + */ +import { Camera, Logger, Vector3, WebGLEngine } from "@galacean/engine"; +import { SpineAnimationRenderer } from "@galacean/engine-spine"; +import "@galacean/engine-spine-core-4.2"; +import { CanvasRenderMode, RectMask2D, UICanvas, UITransform } from "@galacean/engine-ui"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +Logger.enable(); + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity(); + + const cameraEntity = root.createChild("camera"); + const camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 20); + cameraEntity.transform.lookAt(new Vector3(0, 0, 0)); + + engine.resourceManager + .load({ + urls: ["/spineboy.json", "/spineboy.atlas", "/spineboy.png"], + type: "Spine" + }) + .then((resource: any) => { + const canvasEntity = root.createChild("canvas"); + const canvas = canvasEntity.addComponent(UICanvas); + canvas.renderMode = CanvasRenderMode.WorldSpace; + + // Left: unmasked reference. + const referenceEntity = resource.instantiate(); + referenceEntity.transform.setPosition(-4, -3.2, 0); + canvasEntity.addChild(referenceEntity); + referenceEntity.getComponent(SpineAnimationRenderer).state.setAnimation(0, "idle", true); + + // Right: the same skeleton clipped by a RectMask2D to a 8x4 window around the torso. + const maskEntity = canvasEntity.createChild("mask"); + maskEntity.transform.setPosition(4, -1, 0); + const maskTransform = maskEntity.transform as UITransform; + maskTransform.size.set(8, 4); + maskEntity.addComponent(RectMask2D); + + const maskedEntity = resource.instantiate(); + maskedEntity.transform.setPosition(0, -2.2, 0); + maskEntity.addChild(maskedEntity); + maskedEntity.getComponent(SpineAnimationRenderer).state.setAnimation(0, "idle", true); + + updateForE2E(engine); + initScreenshot(engine, camera); + }) + .catch((e) => console.error("CASE ERROR", e)); +}); diff --git a/e2e/config.ts b/e2e/config.ts index d0142dcc29..fe1b99aa74 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -11,6 +11,18 @@ export const E2E_CONFIG = { caseFileName: "spine-tint-black", threshold: 0, diffPercentage: 0.05 + }, + uiCanvas: { + category: "Spine", + caseFileName: "spine-ui-canvas", + threshold: 0, + diffPercentage: 0.05 + }, + uiRectMask: { + category: "Spine", + caseFileName: "spine-ui-rect-mask", + threshold: 0, + diffPercentage: 0.05 } }, Animator: { diff --git a/e2e/fixtures/originImage/Spine_spine-ui-canvas.jpg b/e2e/fixtures/originImage/Spine_spine-ui-canvas.jpg new file mode 100644 index 0000000000..472bb3750a --- /dev/null +++ b/e2e/fixtures/originImage/Spine_spine-ui-canvas.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8776b1e465b5bc6e0e1283b1127cb1f16fa2e7c734e499b0393bb9a35803df12 +size 183234 diff --git a/e2e/fixtures/originImage/Spine_spine-ui-rect-mask.jpg b/e2e/fixtures/originImage/Spine_spine-ui-rect-mask.jpg new file mode 100644 index 0000000000..414bbb2bdb --- /dev/null +++ b/e2e/fixtures/originImage/Spine_spine-ui-rect-mask.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32e0bc2b28bef1eea44760dba37a8cfb2486d880b895d9f7c324efe45b48f630 +size 114620 diff --git a/e2e/package.json b/e2e/package.json index fb743e744a..247877d5e2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -24,6 +24,7 @@ "@galacean/engine-spine-core-4.2": "workspace:*", "dat.gui": "^0.7.9", "vite": "3.1.6", - "sass": "^1.55.0" + "sass": "^1.55.0", + "@galacean/engine-ui": "workspace:*" } } diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 5b0c929ac1..28323d38b5 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -111,6 +111,8 @@ export class Entity extends EngineObject { /** @internal */ _isRoot: boolean = false; /** @internal */ + _uiHierarchyVersion: number = 0; + /** @internal */ _isActive: boolean = true; /** @internal */ _siblingIndex: number = -1; @@ -612,6 +614,18 @@ export class Entity extends EngineObject { this._modifyFlagManager?.removeListener(onChange); } + /** + * @internal + * Stamp the hierarchy version up the parent chain so root canvases detect that their + * subtree changed and rebuild their element list. + */ + _updateUIHierarchyVersion(version: number): void { + if (this._uiHierarchyVersion !== version) { + this._uiHierarchyVersion = version; + this.parent?._updateUIHierarchyVersion(version); + } + } + private _dispatchModify(flag: EntityModifyFlags, param?: any): void { this._modifyFlagManager?.dispatch(flag, param); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf453a04bd..3be6c88399 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,6 +75,7 @@ export * from "./renderingHardwareInterface/index"; export * from "./physics/index"; export * from "./Utils"; export * from "./audio/index"; +export * from "./ui/index"; import { Polyfill } from "./Polyfill"; export { ShaderMacroCollection } from "./shader/ShaderMacroCollection"; diff --git a/packages/core/src/shaderlib/extra/spine.fs.glsl b/packages/core/src/shaderlib/extra/spine.fs.glsl index 4137749806..715dd5001e 100644 --- a/packages/core/src/shaderlib/extra/spine.fs.glsl +++ b/packages/core/src/shaderlib/extra/spine.fs.glsl @@ -10,6 +10,30 @@ varying vec4 v_lightColor; varying vec3 v_darkColor; #endif +#ifdef RENDERER_UI_RECT_CLIP + uniform vec4 renderer_UIRectClipRect; + uniform float renderer_UIRectClipEnabled; + uniform vec4 renderer_UIRectClipSoftness; + uniform float renderer_UIRectClipHardClip; + + varying vec2 v_worldPosition; + + float getUIRectClipAlpha() { + vec4 edgeDistance = vec4( + v_worldPosition.x - renderer_UIRectClipRect.x, + v_worldPosition.y - renderer_UIRectClipRect.y, + renderer_UIRectClipRect.z - v_worldPosition.x, + renderer_UIRectClipRect.w - v_worldPosition.y + ); + vec4 hardClipFactor = step(vec4(0.0), edgeDistance); + vec4 softness = max(renderer_UIRectClipSoftness, vec4(1e-5)); + vec4 softClipFactor = clamp(edgeDistance / softness, 0.0, 1.0); + vec4 useSoftness = step(vec4(1e-5), renderer_UIRectClipSoftness); + vec4 clipFactor = mix(hardClipFactor, softClipFactor, useSoftness); + return clipFactor.x * clipFactor.y * clipFactor.z * clipFactor.w; + } +#endif + void main() { vec4 texColor = texture2D(material_SpineTexture, v_uv); @@ -26,4 +50,23 @@ void main() #else gl_FragColor = texColor * lightColor; #endif + + #ifdef RENDERER_UI_RECT_CLIP + if (renderer_UIRectClipEnabled > 0.5) { + float rectClipAlpha = getUIRectClipAlpha(); + // Non-PMA blending scales rgb by src alpha at blend time; PMA (and PMA-additive, whose + // rgb ignores alpha entirely) needs rgb faded explicitly. + gl_FragColor.a *= rectClipAlpha; + gl_FragColor.rgb *= mix(1.0, rectClipAlpha, renderer_PremultipliedAlpha); + // Hard clip discards on the rect factor, not final alpha: additive slots legitimately + // emit zero-alpha fragments with visible rgb. + if (renderer_UIRectClipHardClip > 0.5 && rectClipAlpha < 0.001) { + discard; + } + } + #endif + + #ifdef ENGINE_SHOULD_SRGB_CORRECT + gl_FragColor = outputSRGBCorrection(gl_FragColor); + #endif } diff --git a/packages/core/src/shaderlib/extra/spine.vs.glsl b/packages/core/src/shaderlib/extra/spine.vs.glsl index ccac4ff172..853f7c0a61 100644 --- a/packages/core/src/shaderlib/extra/spine.vs.glsl +++ b/packages/core/src/shaderlib/extra/spine.vs.glsl @@ -15,6 +15,13 @@ varying vec4 v_lightColor; varying vec3 v_darkColor; #endif +#ifdef RENDERER_UI_RECT_CLIP + // Spine vertices are entity-local (unlike UI chunk vertices, which are pre-transformed to + // world space), so the rect-clip world position must be derived from the model matrix. + uniform mat4 renderer_ModelMat; + varying vec2 v_worldPosition; +#endif + void main() { gl_Position = renderer_MVPMat * vec4(POSITION, 1.0); @@ -25,4 +32,8 @@ void main() #ifdef RENDERER_TINT_BLACK v_darkColor = DARK_COLOR; #endif + + #ifdef RENDERER_UI_RECT_CLIP + v_worldPosition = (renderer_ModelMat * vec4(POSITION, 1.0)).xy; + #endif } diff --git a/packages/core/src/ui/IUICanvas.ts b/packages/core/src/ui/IUICanvas.ts index e90e209aa2..4ae7020860 100644 --- a/packages/core/src/ui/IUICanvas.ts +++ b/packages/core/src/ui/IUICanvas.ts @@ -2,15 +2,21 @@ import { Camera } from "../Camera"; import { Entity } from "../Entity"; import { RenderContext } from "../RenderPipeline/RenderContext"; import { RenderElement } from "../RenderPipeline/RenderElement"; +import { IUIElement } from "./IUIElement"; /** - * @internal + * The canvas contract the engine pipeline and canvas-hosted renderers work against. + * Underscore members are engine-internal plumbing implemented by the ui package's `UICanvas`. */ export interface IUICanvas { entity: Entity; sortOrder: number; _canvasIndex: number; + _isRootCanvas: boolean; + _sortDistance: number; + _realRenderMode: number; _renderElements: RenderElement[]; + _disorderedElements: { length: number; add(element: IUIElement): void; deleteByIndex(index: number): IUIElement }; _canRender(camera: Camera): boolean; _prepareRender(renderContext: RenderContext): void; } diff --git a/packages/core/src/ui/IUIElement.ts b/packages/core/src/ui/IUIElement.ts new file mode 100644 index 0000000000..6fe8f619e5 --- /dev/null +++ b/packages/core/src/ui/IUIElement.ts @@ -0,0 +1,110 @@ +import { Ray, Vector3, Vector4 } from "@galacean/engine-math"; +import { Entity } from "../Entity"; +import { IUICanvas } from "./IUICanvas"; + +/** + * Flags describing a root-canvas level modification, broadcast to its elements. + */ +export enum RootCanvasModifyFlags { + None = 0x0, + ReferenceResolutionPerUnit = 0x1, + All = 0x1 +} + +/** + * Entity modify flags dispatched by UI components, extending `EntityModifyFlags`. + */ +export enum EntityUIModifyFlags { + CanvasEnableInScene = 0x4, + GroupEnableInScene = 0x8 +} + +/** + * Flags describing a UI group modification, broadcast to its elements. + */ +export enum GroupModifyFlags { + None = 0x0, + GlobalAlpha = 0x1, + GlobalInteractive = 0x2, + All = 0x3 +} + +/** + * An element owned by a root UI canvas: tracks its canvas and listens to hierarchy changes. + * Underscore members are engine-internal plumbing maintained by the canvas and `UIElementUtils`. + */ +export interface IUIElement { + entity: Entity; + _rootCanvas: IUICanvas; + _indexInRootCanvas: number; + _rootCanvasListeningEntities: Entity[]; + _isRootCanvasDirty: boolean; + + _getRootCanvas(): IUICanvas; + _rootCanvasListener: (flag: number, param?: any) => void; + _onRootCanvasModify?(flag: RootCanvasModifyFlags): void; +} + +/** + * A UI group: cascades alpha/interactive state to its elements. + */ +export interface IUIGroup { + entity: Entity; + /** Marker used to identify a group component without a ui-package dependency. */ + _isUIGroup: boolean; + _disorderedElements: { length: number; add(element: any): void; deleteByIndex(index: number): any }; + _getGlobalAlpha(): number; +} + +/** + * An element that can belong to a UI group. + */ +export interface IUIGroupAble extends IUIElement { + _group: IUIGroup; + _indexInGroup: number; + _groupListeningEntities: Entity[]; + _isGroupDirty: boolean; + + _globalAlpha?: number; + _globalInteractive?: boolean; + + _getGroup(): IUIGroup; + _onGroupModify(flag: GroupModifyFlags, isPass?: boolean): void; + _groupListener(flag: number): void; +} + +/** + * The raycast hit output a canvas-hosted renderer fills. + */ +export interface IUIHitResult { + entity: Entity; + distance: number; + point: Vector3; + normal: Vector3; + component: any; +} + +/** + * A renderer that is not a ui-package `UIRenderer` but can be hosted by a `UICanvas`: + * when placed under a root canvas it is collected, ordered, group-faded, rect-clipped and + * hit-tested by the canvas instead of rendering through the camera pipeline. + * + * @remarks + * Implementers switch their own `ComponentsManager` renderer registration off while hosted + * and push their render elements into `IUICanvas._renderElements` from `_render`. + */ +export interface IUIHostedRenderer extends IUIGroupAble { + /** Marker checked by the canvas walk. */ + _isUIHostedRenderer: boolean; + + raycastEnabled: boolean; + _raycast(ray: Ray, out: IUIHitResult, distance: number): boolean; + + /** Rect masks assigned by the canvas walk (ui `RectMask2D` instances). */ + _rectMasks: any[]; + _rectMaskRect: Vector4; + _rectMaskSoftness: Vector4; + _rectMaskEnabled: boolean; + _rectMaskHardClip: boolean; + _setRectMasks(rectMasks: any[], count: number): void; +} diff --git a/packages/core/src/ui/UIElementUtils.ts b/packages/core/src/ui/UIElementUtils.ts new file mode 100644 index 0000000000..072e3a0f14 --- /dev/null +++ b/packages/core/src/ui/UIElementUtils.ts @@ -0,0 +1,172 @@ +import { Entity } from "../Entity"; +import { GroupModifyFlags, IUIElement, IUIGroup, IUIGroupAble, RootCanvasModifyFlags } from "./IUIElement"; +import { IUICanvas } from "./IUICanvas"; + +/** + * Root-canvas / group ownership bookkeeping for UI elements and canvas-hosted renderers. + * The ui package's `Utils` delegates here; renderer packages (e.g. spine) call it directly + * so they can be hosted by a `UICanvas` without depending on the ui package. + */ +export class UIElementUtils { + /** + * Monotonic version used by root canvases to detect hierarchy changes: elements stamp it + * onto their entity chain on enable/disable/move, canvases bump it after each rebuild. + * Starts above the Entity field default so the first stamp always propagates. + */ + static _hierarchyCounter = 1; + + static setRootCanvasDirty(element: IUIElement): void { + if (element._isRootCanvasDirty) return; + element._isRootCanvasDirty = true; + this._registerRootCanvas(element, null); + element._onRootCanvasModify?.(RootCanvasModifyFlags.All); + } + + /** + * @param listenerStartEntity - The entity the parent-chain listeners start from + * (the element's own entity; a nested canvas element starts from its parent). + */ + static setRootCanvas(element: IUIElement, rootCanvas: IUICanvas, listenerStartEntity: Entity): void { + element._isRootCanvasDirty = false; + this._registerRootCanvas(element, rootCanvas); + const toEntity = rootCanvas?.entity.parent ?? null; + this._registerListener( + listenerStartEntity, + toEntity, + element._rootCanvasListener, + element._rootCanvasListeningEntities + ); + } + + static cleanRootCanvas(element: IUIElement): void { + this._registerRootCanvas(element, null); + this._unRegisterListener(element._rootCanvasListener, element._rootCanvasListeningEntities); + } + + static searchRootCanvasInParents(startEntity: Entity): IUICanvas { + let entity = startEntity; + while (entity) { + // @ts-ignore + const components = entity._components; + for (let i = 0, n = components.length; i < n; i++) { + const component = components[i]; + if (component.enabled && (component as unknown as IUICanvas)._isRootCanvas === true) { + return component as unknown as IUICanvas; + } + } + entity = entity.parent; + } + return null; + } + + static setGroupDirty(element: IUIGroupAble): void { + if (element._isGroupDirty) return; + element._isGroupDirty = true; + this._registerGroup(element, null); + element._onGroupModify(GroupModifyFlags.All); + } + + /** + * @param listenerStartEntity - The entity the group listeners start from + * (the element's own entity; a nested group element starts from its parent). + */ + static setGroup(element: IUIGroupAble, group: IUIGroup, listenerStartEntity: Entity): void { + element._isGroupDirty = false; + this._registerGroup(element, group); + const rootCanvas = element._getRootCanvas(); + if (rootCanvas) { + const toEntity = group?.entity ?? rootCanvas.entity.parent; + this._registerListener(listenerStartEntity, toEntity, element._groupListener, element._groupListeningEntities); + } else { + this._unRegisterListener(element._groupListener, element._groupListeningEntities); + } + } + + static cleanGroup(element: IUIGroupAble): void { + this._registerGroup(element, null); + this._unRegisterListener(element._groupListener, element._groupListeningEntities); + } + + static searchGroupInParents(startEntity: Entity, rootCanvas: IUICanvas): IUIGroup { + if (!rootCanvas) return null; + let entity = startEntity; + const rootCanvasParent = rootCanvas.entity.parent; + while (entity && entity !== rootCanvasParent) { + // @ts-ignore + const components = entity._components; + for (let i = 0, n = components.length; i < n; i++) { + const component = components[i]; + if (component.enabled && (component as unknown as IUIGroup)._isUIGroup === true) { + return component as unknown as IUIGroup; + } + } + entity = entity.parent; + } + return null; + } + + private static _registerRootCanvas(element: IUIElement, canvas: IUICanvas): void { + const preCanvas = element._rootCanvas; + if (preCanvas !== canvas) { + if (preCanvas) { + const replaced = preCanvas._disorderedElements.deleteByIndex(element._indexInRootCanvas); + replaced && (replaced._indexInRootCanvas = element._indexInRootCanvas); + element._indexInRootCanvas = -1; + } + if (canvas) { + const disorderedElements = canvas._disorderedElements; + element._indexInRootCanvas = disorderedElements.length; + disorderedElements.add(element); + } + element._rootCanvas = canvas; + } + } + + private static _registerGroup(element: IUIGroupAble, group: IUIGroup): void { + const preGroup = element._group; + if (preGroup !== group) { + if (preGroup) { + const replaced = preGroup._disorderedElements.deleteByIndex(element._indexInGroup); + replaced && (replaced._indexInGroup = element._indexInGroup); + element._indexInGroup = -1; + } + if (group) { + const disorderedElements = group._disorderedElements; + element._indexInGroup = disorderedElements.length; + disorderedElements.add(element); + } + element._group = group; + element._onGroupModify(GroupModifyFlags.All); + } + } + + private static _registerListener( + entity: Entity, + root: Entity, + listener: (flag: number, param?: any) => void, + listeningEntities: Entity[] + ): void { + let count = 0; + while (entity && entity !== root) { + const preEntity = listeningEntities[count]; + if (preEntity !== entity) { + // @ts-ignore + preEntity?._unRegisterModifyListener(listener); + listeningEntities[count] = entity; + // @ts-ignore + entity._registerModifyListener(listener); + } + entity = entity.parent; + count++; + } + listeningEntities.length = count; + } + + private static _unRegisterListener(listener: (flag: number, param?: any) => void, listeningEntities: Entity[]): void { + for (let i = 0, n = listeningEntities.length; i < n; i++) { + // @ts-ignore + listeningEntities[i]._unRegisterModifyListener(listener); + } + listeningEntities.length = 0; + } +} diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts new file mode 100644 index 0000000000..87a13a9372 --- /dev/null +++ b/packages/core/src/ui/index.ts @@ -0,0 +1,4 @@ +export { EntityUIModifyFlags, GroupModifyFlags, RootCanvasModifyFlags } from "./IUIElement"; +export type { IUIElement, IUIGroup, IUIGroupAble, IUIHitResult, IUIHostedRenderer } from "./IUIElement"; +export type { IUICanvas } from "./IUICanvas"; +export { UIElementUtils } from "./UIElementUtils"; diff --git a/packages/spine-core-3.8/src/SpineGenerator.ts b/packages/spine-core-3.8/src/SpineGenerator.ts index 5ba048f320..cdb2e63dc1 100644 --- a/packages/spine-core-3.8/src/SpineGenerator.ts +++ b/packages/spine-core-3.8/src/SpineGenerator.ts @@ -37,8 +37,17 @@ export class SpineGenerator { private _clipper: SkeletonClipping = new SkeletonClipping(); buildPrimitive(skeleton: Skeleton, renderer: ISpineRenderTarget) { - const { _indices, _vertices, _localBounds, _vertexCount, _subPrimitives, zSpacing, premultipliedAlpha, tintBlack } = - renderer; + const { + _indices, + _vertices, + _localBounds, + _vertexCount, + _subPrimitives, + zSpacing, + premultipliedAlpha, + tintBlack, + globalAlpha + } = renderer; _localBounds.min.set(Infinity, Infinity, Infinity); _localBounds.max.set(-Infinity, -Infinity, -Infinity); @@ -155,7 +164,7 @@ export class SpineGenerator { const skeletonColor = skeleton.color; const slotColor = slot.color; const finalColor = SpineGenerator.tempColor; - const finalAlpha = skeletonColor.a * slotColor.a * attachmentColor.a; + const finalAlpha = skeletonColor.a * slotColor.a * attachmentColor.a * globalAlpha; finalColor.r = skeletonColor.r * slotColor.r * attachmentColor.r; finalColor.g = skeletonColor.g * slotColor.g * attachmentColor.g; diff --git a/packages/spine-core-4.2/src/SpineGenerator.ts b/packages/spine-core-4.2/src/SpineGenerator.ts index e995320a03..d0ab32f1d0 100644 --- a/packages/spine-core-4.2/src/SpineGenerator.ts +++ b/packages/spine-core-4.2/src/SpineGenerator.ts @@ -37,8 +37,17 @@ export class SpineGenerator { private _clipper: SkeletonClipping = new SkeletonClipping(); buildPrimitive(skeleton: Skeleton, renderer: ISpineRenderTarget) { - const { _indices, _vertices, _localBounds, _vertexCount, _subPrimitives, zSpacing, premultipliedAlpha, tintBlack } = - renderer; + const { + _indices, + _vertices, + _localBounds, + _vertexCount, + _subPrimitives, + zSpacing, + premultipliedAlpha, + tintBlack, + globalAlpha + } = renderer; _localBounds.min.set(Infinity, Infinity, Infinity); _localBounds.max.set(-Infinity, -Infinity, -Infinity); @@ -150,7 +159,7 @@ export class SpineGenerator { const skeletonColor = skeleton.color; const slotColor = slot.color; const finalColor = SpineGenerator.tempColor; - const finalAlpha = skeletonColor.a * slotColor.a * attachmentColor.a; + const finalAlpha = skeletonColor.a * slotColor.a * attachmentColor.a * globalAlpha; finalColor.r = skeletonColor.r * slotColor.r * attachmentColor.r; finalColor.g = skeletonColor.g * slotColor.g * attachmentColor.g; diff --git a/packages/spine/src/index.ts b/packages/spine/src/index.ts index 938f79117d..8c380ef791 100644 --- a/packages/spine/src/index.ts +++ b/packages/spine/src/index.ts @@ -11,6 +11,7 @@ for (let key in LoaderObject) { export * from "./loader/index"; export * from "./renderer/index"; +export { SpineAnimationDefaultConfig } from "./renderer/SpineAnimationRenderer"; export { SpineBlendMode } from "./enums/SpineBlendMode"; export { SpineVertexStride } from "./SpineConstant"; export { registerSpineRuntime, getSpineRuntime } from "./runtime/SpineRuntimeRegistry"; diff --git a/packages/spine/src/renderer/SpineAnimationRenderer.ts b/packages/spine/src/renderer/SpineAnimationRenderer.ts index 2be54a129c..c6e217a8b0 100644 --- a/packages/spine/src/renderer/SpineAnimationRenderer.ts +++ b/packages/spine/src/renderer/SpineAnimationRenderer.ts @@ -7,19 +7,27 @@ import { BufferUsage, deepClone, Entity, + EntityModifyFlags, + EntityUIModifyFlags, ignoreClone, IndexBufferBinding, IndexFormat, Material, Primitive, + Ray, Renderer, + ShaderMacro, + ShaderProperty, SubPrimitive, Texture2D, + UIElementUtils, Vector3, + Vector4, VertexBufferBinding, VertexElement, VertexElementFormat } from "@galacean/engine"; +import type { IUICanvas, IUIGroup, IUIHitResult, IUIHostedRenderer } from "@galacean/engine"; import { SpineResource } from "../loader/SpineResource"; import { SpineMaterial } from "./SpineMaterial"; import { SpineBlendMode } from "../enums/SpineBlendMode"; @@ -29,12 +37,23 @@ import type { ISpineRenderTarget } from "../runtime/ISpineRenderTarget"; /** * Spine animation renderer, capable of rendering spine animations and providing functions for animation and skeleton manipulation. + * + * @remarks + * Renders in world space through the camera pipeline, or — when placed under a root `UICanvas` — + * is hosted by the canvas (implements the engine's `IUIHostedRenderer` contract): collected and + * ordered with the other UI elements, faded by `UIGroup` alpha and clipped by `RectMask2D`. + * The skeleton renders at the entity's transform scale in both spaces. */ -export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarget { +export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarget, IUIHostedRenderer { private static _positionVertexElement = new VertexElement("POSITION", 0, VertexElementFormat.Vector3, 0); private static _lightColorVertexElement = new VertexElement("LIGHT_COLOR", 12, VertexElementFormat.Vector4, 0); private static _uvVertexElement = new VertexElement("TEXCOORD_0", 28, VertexElementFormat.Vector2, 0); private static _darkColorVertexElement = new VertexElement("DARK_COLOR", 36, VertexElementFormat.Vector3, 0); + private static _uiRectClipMacro = ShaderMacro.getByName("RENDERER_UI_RECT_CLIP"); + private static _rectClipEnabledProperty = ShaderProperty.getByName("renderer_UIRectClipEnabled"); + private static _rectClipSoftnessProperty = ShaderProperty.getByName("renderer_UIRectClipSoftness"); + private static _rectClipHardClipProperty = ShaderProperty.getByName("renderer_UIRectClipHardClip"); + private static _tempHitPoint = new Vector3(); /** @internal */ static _materialCacheMap = new Map(); @@ -54,6 +73,13 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg @assignmentClone premultipliedAlpha = false; + /** + * Whether this renderer can be picked up by UI raycasts while hosted inside a `UICanvas`. + * The hit area is the skeleton's world bounds. + */ + @assignmentClone + raycastEnabled = false; + @assignmentClone private _tintBlack = false; @@ -114,6 +140,51 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg new Vector3(-Infinity, -Infinity, -Infinity) ); + /** @internal Marker checked by the `UICanvas` walk (`IUIHostedRenderer`). */ + @ignoreClone + _isUIHostedRenderer = true; + /** @internal */ + @ignoreClone + _rootCanvas: IUICanvas = null; + /** @internal */ + @ignoreClone + _indexInRootCanvas = -1; + /** @internal */ + @ignoreClone + _rootCanvasListeningEntities: Entity[] = []; + /** @internal */ + @ignoreClone + _isRootCanvasDirty = false; + /** @internal */ + @ignoreClone + _group: IUIGroup = null; + /** @internal */ + @ignoreClone + _indexInGroup = -1; + /** @internal */ + @ignoreClone + _groupListeningEntities: Entity[] = []; + /** @internal */ + @ignoreClone + _isGroupDirty = false; + /** @internal */ + @ignoreClone + _rectMasks: any[] = []; + /** @internal */ + @ignoreClone + _rectMaskRect = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskSoftness = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskEnabled = false; + /** @internal */ + @ignoreClone + _rectMaskHardClip = false; + + @ignoreClone + private _hostedByUICanvas = false; @ignoreClone private _skeleton: Skeleton; @ignoreClone @@ -136,6 +207,15 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg return this._skeleton; } + /** + * @internal + * The host-level alpha (UI group alpha while hosted), folded into vertex colors by the + * generator on every rebuild. + */ + get globalAlpha(): number { + return this._hostedByUICanvas ? (this._getGroup()?._getGlobalAlpha() ?? 1) : 1; + } + /** * @internal */ @@ -148,6 +228,12 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg primitive.addVertexElement(SpineAnimationRenderer._lightColorVertexElement); primitive.addVertexElement(SpineAnimationRenderer._uvVertexElement); primitive.addVertexElement(SpineAnimationRenderer._darkColorVertexElement); + this._rootCanvasListener = this._rootCanvasListener.bind(this); + this._groupListener = this._groupListener.bind(this); + const shaderData = this.shaderData; + shaderData.setFloat(SpineAnimationRenderer._rectClipEnabledProperty, 0); + shaderData.setVector4(SpineAnimationRenderer._rectClipSoftnessProperty, this._rectMaskSoftness); + shaderData.setFloat(SpineAnimationRenderer._rectClipHardClipProperty, 0); } /** @@ -170,6 +256,50 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } + /** + * @internal + * Registration switches with the hierarchy: in world space the renderer joins the camera + * pipeline's renderer list; under a root canvas the canvas collects it instead. + */ + // @ts-ignore + override _onEnableInScene(): void { + // @ts-ignore + const componentsManager = this.scene._componentsManager; + // @ts-ignore + this._overrideUpdate && componentsManager.addOnUpdateRenderers(this); + const rootCanvas = UIElementUtils.searchRootCanvasInParents(this.entity); + this._setHostedByUICanvas(!!rootCanvas, componentsManager, false); + if (rootCanvas) { + // @ts-ignore + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + UIElementUtils.setRootCanvasDirty(this); + UIElementUtils.setGroupDirty(this); + } else { + componentsManager.addRenderer(this); + // Listen to the whole parent chain so moving under a canvas re-homes the renderer. + UIElementUtils.setRootCanvas(this, null, this.entity); + } + } + + /** + * @internal + */ + // @ts-ignore + override _onDisableInScene(): void { + // @ts-ignore + const componentsManager = this.scene._componentsManager; + // @ts-ignore + this._overrideUpdate && componentsManager.removeOnUpdateRenderers(this); + if (this._hostedByUICanvas) { + // @ts-ignore + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + } else { + componentsManager.removeRenderer(this); + } + UIElementUtils.cleanRootCanvas(this); + UIElementUtils.cleanGroup(this); + } + /** * @internal */ @@ -179,23 +309,50 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg if (!_subPrimitives) return; // Engine 2.0 render-element model: one RenderElement per sub-primitive (no sub-element pool). const engine = this.engine as any; - const priority = this.priority; - const distanceForSort = (this as any)._distanceForSort; const renderElementPool = engine._renderElementPool; - const renderPipeline = context.camera._renderPipeline; - for (let i = 0, n = _subPrimitives.length; i < n; i++) { - let material = materials[i]; - if (!material) { - continue; + const rootCanvas = this._hostedByUICanvas ? (this._getRootCanvas() as IUICanvas) : null; + if (rootCanvas) { + if (this.globalAlpha <= 0) { + return; } - if (material.destroyed || material.shader.destroyed) { - material = engine._basicResources.meshMagentaMaterial; + const priority = rootCanvas.sortOrder; + const distanceForSort = rootCanvas._sortDistance; + const renderElements = rootCanvas._renderElements; + for (let i = 0, n = _subPrimitives.length; i < n; i++) { + let material = materials[i]; + if (!material) { + continue; + } + if (material.destroyed || material.shader.destroyed) { + material = engine._basicResources.meshMagentaMaterial; + } + const renderElement = renderElementPool.get(); + renderElement.set(this, material, _primitive, _subPrimitives[i]); + // The overlay pass renders canvas elements without the pipeline's pushRenderElement + // (which assigns subShader elsewhere); camera-mode canvases overwrite this later. + renderElement.subShader = material.shader.subShaders[0]; + renderElement.priority = priority; + renderElement.distanceForSort = distanceForSort; + renderElements.push(renderElement); + } + } else { + const priority = this.priority; + const distanceForSort = (this as any)._distanceForSort; + const renderPipeline = context.camera._renderPipeline; + for (let i = 0, n = _subPrimitives.length; i < n; i++) { + let material = materials[i]; + if (!material) { + continue; + } + if (material.destroyed || material.shader.destroyed) { + material = engine._basicResources.meshMagentaMaterial; + } + const renderElement = renderElementPool.get(); + renderElement.set(this, material, _primitive, _subPrimitives[i]); + renderElement.priority = priority; + renderElement.distanceForSort = distanceForSort; + renderPipeline.pushRenderElement(context, renderElement); } - const renderElement = renderElementPool.get(); - renderElement.set(this, material, _primitive, _subPrimitives[i]); - renderElement.priority = priority; - renderElement.distanceForSort = distanceForSort; - renderPipeline.pushRenderElement(context, renderElement); } } @@ -207,6 +364,93 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg BoundingBox.transform(this._localBounds, this.entity.transform.worldMatrix, worldBounds); } + /** + * @internal + */ + _getRootCanvas(): IUICanvas { + if (this._isRootCanvasDirty) { + UIElementUtils.setRootCanvas(this, UIElementUtils.searchRootCanvasInParents(this.entity), this.entity); + } + return this._rootCanvas; + } + + /** + * @internal + */ + _getGroup(): IUIGroup { + if (this._isGroupDirty) { + const rootCanvas = this._getRootCanvas(); + const group = rootCanvas ? UIElementUtils.searchGroupInParents(this.entity, rootCanvas) : null; + UIElementUtils.setGroup(this, group, this.entity); + } + return this._group; + } + + /** + * @internal + * Vertex colors fully rebuild every frame and read the group alpha then, so group + * notifications need no bookkeeping here. + */ + _onGroupModify(): void {} + + /** + * @internal + */ + @ignoreClone + _rootCanvasListener(flag: number, param: any): void { + switch (flag) { + case EntityModifyFlags.Parent: + this._refreshHosting(); + UIElementUtils.setRootCanvasDirty(this); + UIElementUtils.setGroupDirty(this); + case EntityModifyFlags.Child: + // @ts-ignore + (param as Entity)._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + break; + default: + break; + } + } + + /** + * @internal + */ + @ignoreClone + _groupListener(flag: number): void { + if (flag === EntityModifyFlags.Parent || flag === EntityUIModifyFlags.GroupEnableInScene) { + UIElementUtils.setGroupDirty(this); + } + } + + /** + * @internal + */ + _setRectMasks(rectMasks: any[], count: number): void { + const targetMasks = this._rectMasks; + targetMasks.length = count; + for (let i = 0; i < count; i++) { + targetMasks[i] = rectMasks[i]; + } + } + + /** + * @internal + * Hosted hit-testing against the skeleton's world bounds. + */ + _raycast(ray: Ray, out: IUIHitResult, distance: number = Number.MAX_SAFE_INTEGER): boolean { + const curDistance = ray.intersectBox(this.bounds); + if (curDistance >= 0 && curDistance < distance) { + const hitPoint = ray.getPoint(curDistance, SpineAnimationRenderer._tempHitPoint); + out.component = this; + out.distance = curDistance; + out.entity = this.entity; + out.normal.copyFrom(this.entity.transform.worldForward); + out.point.copyFrom(hitPoint); + return true; + } + return false; + } + /** * @internal */ @@ -326,6 +570,42 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg return cached; } + private _setHostedByUICanvas(hosted: boolean, componentsManager: any, switchRegistration: boolean): void { + if (this._hostedByUICanvas === hosted && switchRegistration) return; + this._hostedByUICanvas = hosted; + if (switchRegistration) { + if (hosted) { + componentsManager.removeRenderer(this); + } else { + componentsManager.addRenderer(this); + } + } + if (hosted) { + this.shaderData.enableMacro(SpineAnimationRenderer._uiRectClipMacro); + } else { + this.shaderData.disableMacro(SpineAnimationRenderer._uiRectClipMacro); + } + } + + private _refreshHosting(): void { + const rootCanvas = UIElementUtils.searchRootCanvasInParents(this.entity); + const hosted = !!rootCanvas; + if (hosted !== this._hostedByUICanvas) { + // @ts-ignore + const componentsManager = this.scene._componentsManager; + this._setHostedByUICanvas(hosted, componentsManager, true); + if (hosted) { + // @ts-ignore + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + } else { + // Re-arm whole-chain listeners (the canvas-scoped range no longer covers the new parents). + UIElementUtils.setRootCanvas(this, null, this.entity); + UIElementUtils.cleanGroup(this); + this._rectMasks.length = 0; + } + } + } + private _clearMaterialCache(): void { const materialCache = SpineAnimationRenderer._materialCacheMap; const materials = this._materials; diff --git a/packages/spine/src/runtime/ISpineRenderTarget.ts b/packages/spine/src/runtime/ISpineRenderTarget.ts index 50133ae9f8..ccde6d3c06 100644 --- a/packages/spine/src/runtime/ISpineRenderTarget.ts +++ b/packages/spine/src/runtime/ISpineRenderTarget.ts @@ -5,9 +5,10 @@ import type { SpineBlendMode } from "../enums/SpineBlendMode"; * The write target a spine core package's generator fills while building the renderable primitive. * * @remarks - * Implemented by `SpineAnimationRenderer`. This is the narrow, spine-core-free contract that lets the - * version-specific generator live in a core package while the buffer/material/bounds bookkeeping stays - * in the shared renderer — the analogue of how engine physics colliders expose a native handle. + * Implemented by the render hosts (world-space `SpineAnimationRenderer` and the UI-space renderer). + * This is the narrow, spine-core-free contract that lets the version-specific generator live in a + * core package while the buffer/material/bounds bookkeeping stays in the shared renderer — the + * analogue of how engine physics colliders expose a native handle. */ export interface ISpineRenderTarget { readonly _vertices: Float32Array; @@ -20,6 +21,11 @@ export interface ISpineRenderTarget { readonly zSpacing: number; readonly premultipliedAlpha: boolean; readonly tintBlack: boolean; + /** + * Host-level alpha modulation (e.g. UI group alpha), multiplied into the final vertex alpha + * during generation — before premultiplication, so PMA color/dark-color scaling follows it. + */ + readonly globalAlpha: number; _needResizeBuffer: boolean; _createAndBindBuffer(vertexCount: number): void; diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index 60040d8e75..93ffd28569 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -1,16 +1,50 @@ -import { Entity, Matrix, Plane, Ray, Vector2, Vector3 } from "@galacean/engine"; +import { + Entity, + Matrix, + Plane, + Ray, + ShaderData, + ShaderProperty, + UIElementUtils, + Vector2, + Vector3, + Vector4 +} from "@galacean/engine"; import { UITransform } from "./component"; -import { RootCanvasModifyFlags, UICanvas } from "./component/UICanvas"; -import { GroupModifyFlags, UIGroup } from "./component/UIGroup"; +import { UICanvas } from "./component/UICanvas"; +import { UIGroup } from "./component/UIGroup"; +import { RectMask2D } from "./component/advanced/RectMask2D"; import { CanvasRenderMode } from "./enums/CanvasRenderMode"; -import { IElement } from "./interface/IElement"; -import { IGroupAble } from "./interface/IGroupAble"; +import type { IUIElement, IUIGroupAble } from "@galacean/engine"; + +/** + * The rect-clip state a renderer carries so `RectMask2D` ancestors can clip it in the shader. + * Implemented by `UIRenderer` and by canvas-hosted renderers (e.g. spine). + */ +export interface IRectMaskTarget { + shaderData: ShaderData; + _rectMasks: RectMask2D[]; + _rectMaskRect: Vector4; + _rectMaskSoftness: Vector4; + _rectMaskEnabled: boolean; + _rectMaskHardClip: boolean; +} export class Utils { static _tempRay: Ray = new Ray(); static _tempPlane: Plane = new Plane(); static _tempVec3: Vector3 = new Vector3(); static _tempMat: Matrix = new Matrix(); + static _tempRect: Vector4 = new Vector4(); + + /** @internal */ + static _rectClipRectProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipRect"); + /** @internal */ + static _rectClipEnabledProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipEnabled"); + /** @internal */ + static _rectClipSoftnessProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipSoftness"); + /** @internal */ + static _rectClipHardClipProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipHardClip"); /** * Local position of a screen point in the component @@ -62,148 +96,162 @@ export class Utils { return false; } - static setRootCanvasDirty(element: IElement): void { - if (element._isRootCanvasDirty) return; - element._isRootCanvasDirty = true; - this._registerRootCanvas(element, null); - element._onRootCanvasModify?.(RootCanvasModifyFlags.All); + static setRootCanvasDirty(element: IUIElement): void { + UIElementUtils.setRootCanvasDirty(element); } - static setRootCanvas(element: IElement, rootCanvas: UICanvas): void { - element._isRootCanvasDirty = false; - this._registerRootCanvas(element, rootCanvas); + static setRootCanvas(element: IUIElement, rootCanvas: UICanvas): void { const fromEntity = element instanceof UICanvas ? element.entity.parent : element.entity; - const toEntity = rootCanvas?.entity.parent ?? null; - this._registerListener(fromEntity, toEntity, element._rootCanvasListener, element._rootCanvasListeningEntities); + UIElementUtils.setRootCanvas(element, rootCanvas, fromEntity); } - static cleanRootCanvas(element: IElement): void { - this._registerRootCanvas(element, null); - this._unRegisterListener(element._rootCanvasListener, element._rootCanvasListeningEntities); + static cleanRootCanvas(element: IUIElement): void { + UIElementUtils.cleanRootCanvas(element); } - static searchRootCanvasInParents(element: IElement): UICanvas { - let entity = element instanceof UICanvas ? element.entity.parent : element.entity; - while (entity) { - // @ts-ignore - const components = entity._components; - for (let i = 0, n = components.length; i < n; i++) { - const component = components[i]; - if (component.enabled && component instanceof UICanvas && component._isRootCanvas) { - return component; - } - } - entity = entity.parent; - } - return null; + static searchRootCanvasInParents(element: IUIElement): UICanvas { + const startEntity = element instanceof UICanvas ? element.entity.parent : element.entity; + return UIElementUtils.searchRootCanvasInParents(startEntity); } - static setGroupDirty(element: IGroupAble): void { - if (element._isGroupDirty) return; - element._isGroupDirty = true; - this._registerGroup(element, null); - element._onGroupModify(GroupModifyFlags.All); + static setGroupDirty(element: IUIGroupAble): void { + UIElementUtils.setGroupDirty(element); } - static setGroup(element: IGroupAble, group: UIGroup): void { - element._isGroupDirty = false; - this._registerGroup(element, group); - const rootCanvas = element._getRootCanvas(); - if (rootCanvas) { - const fromEntity = element instanceof UIGroup ? element.entity.parent : element.entity; - const toEntity = group?.entity ?? rootCanvas.entity.parent; - this._registerListener(fromEntity, toEntity, element._groupListener, element._groupListeningEntities); - } else { - this._unRegisterListener(element._groupListener, element._groupListeningEntities); - } + static setGroup(element: IUIGroupAble, group: UIGroup): void { + const fromEntity = element instanceof UIGroup ? element.entity.parent : element.entity; + UIElementUtils.setGroup(element, group, fromEntity); } - static cleanGroup(element: IGroupAble): void { - this._registerGroup(element, null); - this._unRegisterListener(element._groupListener, element._groupListeningEntities); + static cleanGroup(element: IUIGroupAble): void { + UIElementUtils.cleanGroup(element); } - static searchGroupInParents(element: IGroupAble): UIGroup { + static searchGroupInParents(element: IUIGroupAble): UIGroup { const rootCanvas = element._getRootCanvas(); if (!rootCanvas) return null; - let entity = element instanceof UIGroup ? element.entity.parent : element.entity; - const rootCanvasParent = rootCanvas.entity.parent; - while (entity && entity !== rootCanvasParent) { - // @ts-ignore - const components = entity._components; - for (let i = 0, n = components.length; i < n; i++) { - const component = components[i]; - if (component.enabled && component instanceof UIGroup) { - return component; - } - } - entity = entity.parent; - } - return null; + const startEntity = element instanceof UIGroup ? element.entity.parent : element.entity; + return UIElementUtils.searchGroupInParents(startEntity, rootCanvas); } - private static _registerRootCanvas(element: IElement, canvas: UICanvas): void { - const preCanvas = element._rootCanvas; - if (preCanvas !== canvas) { - if (preCanvas) { - const replaced = preCanvas._disorderedElements.deleteByIndex(element._indexInRootCanvas); - replaced && (replaced._indexInRootCanvas = element._indexInRootCanvas); - element._indexInRootCanvas = -1; - } - if (canvas) { - const disorderedElements = canvas._disorderedElements; - element._indexInRootCanvas = disorderedElements.length; - disorderedElements.add(element); - } - element._rootCanvas = canvas; + /** + * @internal + * Recompute the target's rect-clip shader state from its assigned `RectMask2D` list. + */ + static updateRectMaskClipState(target: IRectMaskTarget): void { + const rectMasks = target._rectMasks; + const count = rectMasks.length; + if (count <= 0) { + this.resetRectMaskClipState(target); + return; } - } - private static _registerGroup(element: IGroupAble, group: UIGroup): void { - const preGroup = element._group; - if (preGroup !== group) { - if (preGroup) { - const replaced = preGroup._disorderedElements.deleteByIndex(element._indexInGroup); - replaced && (replaced._indexInGroup = element._indexInGroup); - element._indexInGroup = -1; + let minX = Number.NEGATIVE_INFINITY; + let minY = Number.NEGATIVE_INFINITY; + let maxX = Number.POSITIVE_INFINITY; + let maxY = Number.POSITIVE_INFINITY; + let clipSoftnessLeft = 0; + let clipSoftnessBottom = 0; + let clipSoftnessRight = 0; + let clipSoftnessTop = 0; + let clipHardClip = false; + let hasActiveMask = false; + const tempRect = Utils._tempRect; + for (let i = 0; i < count; i++) { + const rectMask = rectMasks[i]; + if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { + continue; + } + hasActiveMask = true; + const softness = rectMask.softness; + if (!clipHardClip && rectMask.alphaClip) { + clipHardClip = true; + } + if (!rectMask._getWorldRect(tempRect)) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + break; + } + if (tempRect.x > minX) { + minX = tempRect.x; + clipSoftnessLeft = softness.x; + } + if (tempRect.y > minY) { + minY = tempRect.y; + clipSoftnessBottom = softness.y; } - if (group) { - const disorderedElements = group._disorderedElements; - element._indexInGroup = disorderedElements.length; - disorderedElements.add(element); + if (tempRect.z < maxX) { + maxX = tempRect.z; + clipSoftnessRight = softness.x; + } + if (tempRect.w < maxY) { + maxY = tempRect.w; + clipSoftnessTop = softness.y; } - element._group = group; - element._onGroupModify(GroupModifyFlags.All); } - } - private static _registerListener( - entity: Entity, - root: Entity, - listener: (flag: number, param?: any) => void, - listeningEntities: Entity[] - ): void { - let count = 0; - while (entity && entity !== root) { - const preEntity = listeningEntities[count]; - if (preEntity !== entity) { - // @ts-ignore - preEntity?._unRegisterModifyListener(listener); - listeningEntities[count] = entity; - // @ts-ignore - entity._registerModifyListener(listener); - } - entity = entity.parent; - count++; + if (!hasActiveMask) { + this.resetRectMaskClipState(target); + return; + } + + if (minX >= maxX || minY >= maxY) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + clipSoftnessLeft = 0; + clipSoftnessBottom = 0; + clipSoftnessRight = 0; + clipSoftnessTop = 0; + } + + const rectMaskRect = target._rectMaskRect; + if (rectMaskRect.x !== minX || rectMaskRect.y !== minY || rectMaskRect.z !== maxX || rectMaskRect.w !== maxY) { + rectMaskRect.set(minX, minY, maxX, maxY); + target.shaderData.setVector4(Utils._rectClipRectProperty, rectMaskRect); + } + + const rectMaskSoftness = target._rectMaskSoftness; + if ( + rectMaskSoftness.x !== clipSoftnessLeft || + rectMaskSoftness.y !== clipSoftnessBottom || + rectMaskSoftness.z !== clipSoftnessRight || + rectMaskSoftness.w !== clipSoftnessTop + ) { + rectMaskSoftness.set(clipSoftnessLeft, clipSoftnessBottom, clipSoftnessRight, clipSoftnessTop); + target.shaderData.setVector4(Utils._rectClipSoftnessProperty, rectMaskSoftness); + } + + if (target._rectMaskHardClip !== clipHardClip) { + target._rectMaskHardClip = clipHardClip; + target.shaderData.setFloat(Utils._rectClipHardClipProperty, clipHardClip ? 1 : 0); + } + + if (!target._rectMaskEnabled) { + target._rectMaskEnabled = true; + target.shaderData.setFloat(Utils._rectClipEnabledProperty, 1); } - listeningEntities.length = count; } - private static _unRegisterListener(listener: (flag: number, param?: any) => void, listeningEntities: Entity[]): void { - for (let i = 0, n = listeningEntities.length; i < n; i++) { - // @ts-ignore - listeningEntities[i]._unRegisterModifyListener(listener); + /** + * @internal + */ + static resetRectMaskClipState(target: IRectMaskTarget): void { + if (target._rectMaskEnabled) { + target._rectMaskEnabled = false; + target.shaderData.setFloat(Utils._rectClipEnabledProperty, 0); + } + const rectMaskSoftness = target._rectMaskSoftness; + if (rectMaskSoftness.x !== 0 || rectMaskSoftness.y !== 0 || rectMaskSoftness.z !== 0 || rectMaskSoftness.w !== 0) { + rectMaskSoftness.set(0, 0, 0, 0); + target.shaderData.setVector4(Utils._rectClipSoftnessProperty, rectMaskSoftness); + } + if (target._rectMaskHardClip) { + target._rectMaskHardClip = false; + target.shaderData.setFloat(Utils._rectClipHardClipProperty, 0); } - listeningEntities.length = 0; } } diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index 77dad56014..7fe9cb60c7 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -7,10 +7,14 @@ import { DisorderedArray, Entity, EntityModifyFlags, + EntityUIModifyFlags, Logger, MathUtil, Matrix, Ray, + Renderer, + RootCanvasModifyFlags, + UIElementUtils, Vector2, Vector3, assignmentClone, @@ -19,6 +23,7 @@ import { ignoreClone, CloneUtils } from "@galacean/engine"; +import type { IUIGroupAble, IUIHostedRenderer } from "@galacean/engine"; import { Utils } from "../Utils"; import { CanvasRenderMode } from "../enums/CanvasRenderMode"; import { ResolutionAdaptationMode } from "../enums/ResolutionAdaptationMode"; @@ -37,9 +42,7 @@ import { UIInteractive } from "./interactive/UIInteractive"; */ @dependentComponents(UITransform, DependentMode.AutoAdd) export class UICanvas extends Component implements IElement { - /** @internal */ - static _hierarchyCounter: number = 1; - private static _tempGroupAbleList: IGroupAble[] = []; + private static _tempGroupAbleList: IUIGroupAble[] = []; private static _tempRectMaskList: RectMask2D[] = []; private static _tempVec3: Vector3 = new Vector3(); private static _tempMat: Matrix = new Matrix(); @@ -70,7 +73,7 @@ export class UICanvas extends Component implements IElement { _sortDistance: number = 0; /** @internal */ @ignoreClone - _orderedRenderers: UIRenderer[] = []; + _orderedRenderers: CanvasRenderable[] = []; /** @internal */ @ignoreClone _realRenderMode: number = CanvasRealRenderMode.None; @@ -340,6 +343,12 @@ export class UICanvas extends Component implements IElement { break; } } + if (!(renderer instanceof UIRenderer)) { + // Hosted renderers don't run UIRenderer's prepare path, so the canvas applies + // their rect-clip state before rendering. + Utils.updateRectMaskClipState(renderer); + } + // @ts-ignore renderer._prepareRender(context); renderer._renderFrameCount = frameCount; } @@ -415,7 +424,7 @@ export class UICanvas extends Component implements IElement { target.renderMode = this._renderMode; } - private _getRenderers(): UIRenderer[] { + private _getRenderers(): CanvasRenderable[] { const { _orderedRenderers: renderers, entity } = this; const uiHierarchyVersion = entity._uiHierarchyVersion; if (this._hierarchyVersion !== uiHierarchyVersion) { @@ -423,7 +432,7 @@ export class UICanvas extends Component implements IElement { renderers.length = this._walk(this.entity, renderers, 0, null, 0); UICanvas._tempGroupAbleList.length = 0; this._hierarchyVersion = uiHierarchyVersion; - ++UICanvas._hierarchyCounter; + ++UIElementUtils._hierarchyCounter; } return renderers; } @@ -504,7 +513,7 @@ export class UICanvas extends Component implements IElement { private _walk( entity: Entity, - renderers: UIRenderer[], + renderers: CanvasRenderable[], depth = 0, group: UIGroup = null, rectMaskCount: number = 0 @@ -518,14 +527,15 @@ export class UICanvas extends Component implements IElement { for (let i = 0, n = components.length; i < n; i++) { const component = components[i]; if (!component.enabled) continue; - if (component instanceof UIRenderer) { - renderers[depth] = component; + if (component instanceof UIRenderer || (component as unknown as IUIHostedRenderer)._isUIHostedRenderer) { + const renderable = component as unknown as CanvasRenderable; + renderers[depth] = renderable; ++depth; - component._isRootCanvasDirty && Utils.setRootCanvas(component, this); - if (component._isGroupDirty) { - tempGroupAbleList[groupAbleCount++] = component; + renderable._isRootCanvasDirty && Utils.setRootCanvas(renderable, this); + if (renderable._isGroupDirty) { + tempGroupAbleList[groupAbleCount++] = renderable; } - component._setRectMasks(tempRectMaskList, rectMaskCount); + renderable._setRectMasks(tempRectMaskList, rectMaskCount); } else if (component instanceof UIInteractive) { component._isRootCanvasDirty && Utils.setRootCanvas(component, this); if (component._isGroupDirty) { @@ -637,7 +647,7 @@ export class UICanvas extends Component implements IElement { this._updateCameraObserver(); this._setRealRenderMode(this._getRealRenderMode()); if (isRootCanvas) { - this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); } else { const { _disorderedElements: disorderedElements } = this; disorderedElements.forEach((element: IElement) => { @@ -731,16 +741,12 @@ enum CanvasRealRenderMode { None = 4 } +export { EntityUIModifyFlags } from "@galacean/engine"; + +export { RootCanvasModifyFlags } from "@galacean/engine"; + /** - * @remarks Extends `EntityModifyFlags`. + * A renderer a root canvas collects and drives: either a ui `UIRenderer` or any engine + * `Renderer` implementing the core `IUIHostedRenderer` hosting contract. */ -export enum EntityUIModifyFlags { - CanvasEnableInScene = 0x4, - GroupEnableInScene = 0x8 -} - -export enum RootCanvasModifyFlags { - None = 0x0, - ReferenceResolutionPerUnit = 0x1, - All = 0x1 -} +export type CanvasRenderable = UIRenderer | (Renderer & IUIHostedRenderer); diff --git a/packages/ui/src/component/UIGroup.ts b/packages/ui/src/component/UIGroup.ts index b876e05ec4..6ca1114ac5 100644 --- a/packages/ui/src/component/UIGroup.ts +++ b/packages/ui/src/component/UIGroup.ts @@ -1,9 +1,19 @@ -import { Component, DisorderedArray, Entity, EntityModifyFlags, assignmentClone, ignoreClone } from "@galacean/engine"; +import { + Component, + DisorderedArray, + Entity, + EntityModifyFlags, + GroupModifyFlags, + assignmentClone, + ignoreClone +} from "@galacean/engine"; import { Utils } from "../Utils"; import { IGroupAble } from "../interface/IGroupAble"; import { EntityUIModifyFlags, UICanvas } from "./UICanvas"; export class UIGroup extends Component implements IGroupAble { + /** @internal Marker letting core-level code identify a group without a ui-package dependency. */ + _isUIGroup = true; /** @internal */ @ignoreClone _indexInGroup: number = -1; @@ -218,9 +228,4 @@ export class UIGroup extends Component implements IGroupAble { } } -export enum GroupModifyFlags { - None = 0x0, - GlobalAlpha = 0x1, - GlobalInteractive = 0x2, - All = 0x3 -} +export { GroupModifyFlags } from "@galacean/engine"; diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index 527ad3bd23..35bf8c874a 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -13,6 +13,7 @@ import { ShaderProperty, SpriteMaskInteraction, SpriteMaskLayer, + UIElementUtils, Vector3, Vector4, assignmentClone, @@ -44,14 +45,6 @@ export class UIRenderer extends Renderer implements IGraphics { /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_UITexture"); /** @internal */ - static _rectClipRectProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipRect"); - /** @internal */ - static _rectClipEnabledProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipEnabled"); - /** @internal */ - static _rectClipSoftnessProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipSoftness"); - /** @internal */ - static _rectClipHardClipProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipHardClip"); - /** @internal */ static _tempRect: Vector4 = new Vector4(); /** @@ -165,9 +158,9 @@ export class UIRenderer extends Renderer implements IGraphics { this._color._onValueChanged = this._onColorChanged; this._groupListener = this._groupListener.bind(this); this._rootCanvasListener = this._rootCanvasListener.bind(this); - this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); - this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, this._rectMaskSoftness); - this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, 0); + this.shaderData.setFloat(Utils._rectClipEnabledProperty, 0); + this.shaderData.setVector4(Utils._rectClipSoftnessProperty, this._rectMaskSoftness); + this.shaderData.setFloat(Utils._rectClipHardClipProperty, 0); } // @ts-ignore @@ -193,7 +186,7 @@ export class UIRenderer extends Renderer implements IGraphics { this._update(context); } - this._updateRectMaskClipState(); + Utils.updateRectMaskClipState(this); this._render(context); // union camera global macro and renderer macro. @@ -210,7 +203,7 @@ export class UIRenderer extends Renderer implements IGraphics { override _onEnableInScene(): void { // @ts-ignore this._overrideUpdate && this.scene._componentsManager.addOnUpdateRenderers(this); - this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); Utils.setRootCanvasDirty(this); Utils.setGroupDirty(this); } @@ -219,7 +212,7 @@ export class UIRenderer extends Renderer implements IGraphics { override _onDisableInScene(): void { // @ts-ignore this._overrideUpdate && this.scene._componentsManager.removeOnUpdateRenderers(this); - this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); Utils.cleanRootCanvas(this); Utils.cleanGroup(this); } @@ -267,7 +260,7 @@ export class UIRenderer extends Renderer implements IGraphics { Utils.setRootCanvasDirty(this); Utils.setGroupDirty(this); case EntityModifyFlags.Child: - entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); break; default: break; @@ -375,120 +368,6 @@ export class UIRenderer extends Renderer implements IGraphics { return true; } - private _updateRectMaskClipState(): void { - const rectMasks = this._rectMasks; - const count = rectMasks.length; - if (count <= 0) { - this._resetRectMaskClipState(); - return; - } - - let minX = Number.NEGATIVE_INFINITY; - let minY = Number.NEGATIVE_INFINITY; - let maxX = Number.POSITIVE_INFINITY; - let maxY = Number.POSITIVE_INFINITY; - let clipSoftnessLeft = 0; - let clipSoftnessBottom = 0; - let clipSoftnessRight = 0; - let clipSoftnessTop = 0; - let clipHardClip = false; - let hasActiveMask = false; - const tempRect = UIRenderer._tempRect; - for (let i = 0; i < count; i++) { - const rectMask = rectMasks[i]; - if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { - continue; - } - hasActiveMask = true; - const softness = rectMask.softness; - if (!clipHardClip && rectMask.alphaClip) { - clipHardClip = true; - } - if (!rectMask._getWorldRect(tempRect)) { - minX = 1; - minY = 1; - maxX = 0; - maxY = 0; - break; - } - if (tempRect.x > minX) { - minX = tempRect.x; - clipSoftnessLeft = softness.x; - } - if (tempRect.y > minY) { - minY = tempRect.y; - clipSoftnessBottom = softness.y; - } - if (tempRect.z < maxX) { - maxX = tempRect.z; - clipSoftnessRight = softness.x; - } - if (tempRect.w < maxY) { - maxY = tempRect.w; - clipSoftnessTop = softness.y; - } - } - - if (!hasActiveMask) { - this._resetRectMaskClipState(); - return; - } - - if (minX >= maxX || minY >= maxY) { - minX = 1; - minY = 1; - maxX = 0; - maxY = 0; - clipSoftnessLeft = 0; - clipSoftnessBottom = 0; - clipSoftnessRight = 0; - clipSoftnessTop = 0; - } - - const rectMaskRect = this._rectMaskRect; - if (rectMaskRect.x !== minX || rectMaskRect.y !== minY || rectMaskRect.z !== maxX || rectMaskRect.w !== maxY) { - rectMaskRect.set(minX, minY, maxX, maxY); - this.shaderData.setVector4(UIRenderer._rectClipRectProperty, rectMaskRect); - } - - const rectMaskSoftness = this._rectMaskSoftness; - if ( - rectMaskSoftness.x !== clipSoftnessLeft || - rectMaskSoftness.y !== clipSoftnessBottom || - rectMaskSoftness.z !== clipSoftnessRight || - rectMaskSoftness.w !== clipSoftnessTop - ) { - rectMaskSoftness.set(clipSoftnessLeft, clipSoftnessBottom, clipSoftnessRight, clipSoftnessTop); - this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); - } - - if (this._rectMaskHardClip !== clipHardClip) { - this._rectMaskHardClip = clipHardClip; - this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, clipHardClip ? 1 : 0); - } - - if (!this._rectMaskEnabled) { - this._rectMaskEnabled = true; - this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 1); - } - } - - private _resetRectMaskClipState(): void { - if (this._rectMaskEnabled) { - this._rectMaskEnabled = false; - this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); - } - const rectMaskSoftness = this._rectMaskSoftness; - if (rectMaskSoftness.x !== 0 || rectMaskSoftness.y !== 0 || rectMaskSoftness.z !== 0 || rectMaskSoftness.w !== 0) { - rectMaskSoftness.set(0, 0, 0, 0); - this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); - } - if (this._rectMaskHardClip) { - this._rectMaskHardClip = false; - this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, 0); - } - } - protected override _onDestroy(): void { if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); diff --git a/packages/ui/src/component/advanced/RectMask2D.ts b/packages/ui/src/component/advanced/RectMask2D.ts index 9cba135e84..b4350d5b33 100644 --- a/packages/ui/src/component/advanced/RectMask2D.ts +++ b/packages/ui/src/component/advanced/RectMask2D.ts @@ -2,6 +2,7 @@ import { Component, DependentMode, Entity, + UIElementUtils, Vector2, Vector3, Vector4, @@ -112,12 +113,12 @@ export class RectMask2D extends Component { // @ts-ignore override _onEnableInScene(): void { - this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); } // @ts-ignore override _onDisableInScene(): void { - this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); } // @ts-ignore diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts index 8329d1f39a..69dc43eeab 100644 --- a/packages/ui/src/component/index.ts +++ b/packages/ui/src/component/index.ts @@ -8,6 +8,7 @@ export { ScaleTransition } from "./interactive/transition/ScaleTransition"; export { SpriteTransition } from "./interactive/transition/SpriteTransition"; export { Transition } from "./interactive/transition/Transition"; export { UICanvas } from "./UICanvas"; +export type { CanvasRenderable } from "./UICanvas"; export { UIGroup } from "./UIGroup"; export { UIRenderer } from "./UIRenderer"; export { UITransform } from "./UITransform"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ea5b3a6b38..9a0d4d0822 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,7 +3,6 @@ import { BlendOperation, CullMode, Engine, - Entity, Loader, Material, PipelineStage, @@ -46,17 +45,6 @@ export class EngineExtension { } } -export class EntityExtension { - _uiHierarchyVersion = 0; - _updateUIHierarchyVersion(version: number): void { - if (this._uiHierarchyVersion !== version) { - this._uiHierarchyVersion = version; - // @ts-ignore - this.parent?._updateUIHierarchyVersion(version); - } - } -} - declare module "@galacean/engine" { interface Engine { // @internal @@ -85,7 +73,6 @@ function ApplyMixins(derivedCtor: any, baseCtors: any[]): void { } ApplyMixins(Engine, [EngineExtension]); -ApplyMixins(Entity, [EntityExtension]); /** * Register GUI components for the editor. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a5d492e97..0f6ff0efca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@galacean/engine-toolkit': specifier: ^1.3.9 version: 1.3.9(@galacean/engine@packages+galacean) + '@galacean/engine-ui': + specifier: workspace:* + version: link:../packages/ui dat.gui: specifier: ^0.7.9 version: 0.7.9 diff --git a/tests/src/spine/SpineAnimationRendererUI.test.ts b/tests/src/spine/SpineAnimationRendererUI.test.ts new file mode 100644 index 0000000000..931108557b --- /dev/null +++ b/tests/src/spine/SpineAnimationRendererUI.test.ts @@ -0,0 +1,313 @@ +import { Camera, Entity, Ray, Texture2D, Vector3, WebGLEngine } from "@galacean/engine"; +import { getSpineRuntime, SpineAnimationRenderer, SpineResource } from "@galacean/engine-spine"; +import "@galacean/engine-spine-core-4.2"; +import { CanvasRenderMode, RectMask2D, UICanvas, UIGroup } from "@galacean/engine-ui"; +import { beforeAll, describe, expect, it } from "vitest"; + +// Legacy-format atlas text (also parsed by the 4.2 parser): one 4x4 page with one region. +const ATLAS_TEXT = ` +page.png +size: 4,4 +format: RGBA8888 +filter: Linear,Linear +repeat: none +region + rotate: false + xy: 0, 0 + size: 4, 4 + orig: 4, 4 + offset: 0, 0 + index: -1 +`; + +const SKELETON_JSON = JSON.stringify({ + skeleton: { spine: "4.2.0" }, + bones: [{ name: "root" }], + slots: [{ name: "slot0", bone: "root", attachment: "region" }], + skins: [{ name: "default", attachments: { slot0: { region: { width: 4, height: 4 } } } }], + animations: { idle: {} } +}); + +function createSpineResource(engine: WebGLEngine): SpineResource { + const runtime = getSpineRuntime(); + const texture = new Texture2D(engine, 4, 4); + const atlas = runtime.createTextureAtlas(ATLAS_TEXT, [texture]); + const skeletonData = runtime.createSkeletonData(SKELETON_JSON, atlas, 1); + return new SpineResource(engine, skeletonData, "test.json"); +} + +function pump(engine: WebGLEngine, times = 2): void { + for (let i = 0; i < times; i++) { + engine.update(); + } +} + +describe("SpineAnimationRenderer inside UICanvas", () => { + let engine: WebGLEngine; + let rootEntity: Entity; + let camera: Camera; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + const scene = engine.sceneManager.scenes[0]; + rootEntity = scene.createRootEntity("root"); + const cameraEntity = rootEntity.createChild("camera"); + camera = cameraEntity.addComponent(Camera); + cameraEntity.transform.setPosition(0, 0, 20); + }); + + function createCanvas(renderMode: CanvasRenderMode): { canvasEntity: Entity; canvas: UICanvas } { + const canvasEntity = rootEntity.createChild("canvas"); + const canvas = canvasEntity.addComponent(UICanvas); + canvas.renderMode = renderMode; + if (renderMode === CanvasRenderMode.ScreenSpaceCamera) { + canvas.renderCamera = camera; + } + return { canvasEntity, canvas }; + } + + function worldRendererCount(): number { + // @ts-ignore + return engine.sceneManager.scenes[0]._componentsManager._renderers.length; + } + + it("is collected by a world-space canvas and pushes one render element per sub-primitive", () => { + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + renderer.state.setAnimation(0, "idle", true); + pump(engine); + + const renderElements = (canvas as any)._renderElements; + expect(renderElements.length).to.equal(1); + expect(renderElements[0].component).to.equal(renderer); + expect(renderElements[0].primitive).to.equal((renderer as any)._primitive); + expect(renderElements[0].subShader).to.not.be.undefined; + canvasEntity.destroy(); + }); + + it("does not join the camera pipeline's renderer list while hosted", () => { + const before = worldRendererCount(); + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + spineEntity.addComponent(SpineAnimationRenderer); + expect(worldRendererCount()).to.equal(before); + canvasEntity.destroy(); + }); + + it("renders through the camera pipeline when not under a canvas", () => { + const before = worldRendererCount(); + const spineEntity = rootEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + expect(worldRendererCount()).to.equal(before + 1); + expect((renderer as any).globalAlpha).to.equal(1); + spineEntity.destroy(); + expect(worldRendererCount()).to.equal(before); + }); + + it("renders in the screen-space overlay pass with subShader assigned", () => { + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.ScreenSpaceOverlay); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + const renderElements = (canvas as any)._renderElements; + expect(renderElements.length).to.equal(1); + expect(renderElements[0].component).to.equal(renderer); + expect(renderElements[0].subShader).to.equal(renderElements[0].material.shader.subShaders[0]); + canvasEntity.destroy(); + }); + + it("re-homes between world and canvas hosting when reparented", () => { + const before = worldRendererCount(); + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = rootEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + // World-hosted at first: registered in the camera pipeline. + expect(worldRendererCount()).to.equal(before + 1); + pump(engine); + expect((canvas as any)._renderElements.length).to.equal(0); + + // Move under the canvas: leaves the camera pipeline, collected by the canvas. + canvasEntity.addChild(spineEntity); + expect(worldRendererCount()).to.equal(before); + pump(engine); + expect((canvas as any)._renderElements.length).to.equal(1); + expect((canvas as any)._renderElements[0].component).to.equal(renderer); + + // Move back out: joins the camera pipeline again, canvas stops collecting it. + rootEntity.addChild(spineEntity); + expect(worldRendererCount()).to.equal(before + 1); + pump(engine); + expect((canvas as any)._renderElements.length).to.equal(0); + + spineEntity.destroy(); + canvasEntity.destroy(); + }); + + it("folds UIGroup alpha into vertex colors on every rebuild", () => { + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const group = canvasEntity.addComponent(UIGroup); + group.alpha = 0.5; + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + // Vertex layout without tintBlack: [x, y, z, r, g, b, a, u, v] — alpha at float 6. + const vertices = (renderer as any)._vertices as Float32Array; + expect(vertices[6]).to.be.closeTo(0.5, 1e-5); + // Straight alpha keeps rgb unscaled. + expect(vertices[3]).to.be.closeTo(1, 1e-5); + + // Alpha is re-applied every frame because vertices fully rebuild. + group.alpha = 0.25; + pump(engine, 1); + expect(vertices[6]).to.be.closeTo(0.25, 1e-5); + canvasEntity.destroy(); + }); + + it("premultiplies group alpha into rgb when premultipliedAlpha is enabled", () => { + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const group = canvasEntity.addComponent(UIGroup); + group.alpha = 0.5; + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.premultipliedAlpha = true; + renderer.resource = createSpineResource(engine); + pump(engine); + + const vertices = (renderer as any)._vertices as Float32Array; + expect(vertices[6]).to.be.closeTo(0.5, 1e-5); + expect(vertices[3]).to.be.closeTo(0.5, 1e-5); + canvasEntity.destroy(); + }); + + it("skips pushing render elements when the group alpha is 0", () => { + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.WorldSpace); + const group = canvasEntity.addComponent(UIGroup); + group.alpha = 0; + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + expect((canvas as any)._renderElements.length).to.equal(0); + canvasEntity.destroy(); + }); + + it("toggles the rect-clip shader macro with hosting", () => { + const spineEntity = rootEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + const isMacroEnabled = () => + // @ts-ignore + renderer.shaderData._macroCollection.isEnable( + // @ts-ignore + (SpineAnimationRenderer as any)._uiRectClipMacro + ); + expect(isMacroEnabled()).to.be.false; + + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + canvasEntity.addChild(spineEntity); + expect(isMacroEnabled()).to.be.true; + + rootEntity.addChild(spineEntity); + expect(isMacroEnabled()).to.be.false; + + spineEntity.destroy(); + canvasEntity.destroy(); + }); + + it("receives rect masks from the canvas walk and enables the clip state", () => { + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const maskEntity = canvasEntity.createChild("mask"); + maskEntity.addComponent(RectMask2D); + const spineEntity = maskEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + expect((renderer as any)._rectMasks.length).to.equal(1); + expect(renderer.shaderData.getFloat("renderer_UIRectClipEnabled")).to.equal(1); + canvasEntity.destroy(); + }); + + it("hit-tests against the skeleton world bounds when raycastEnabled", () => { + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + const out = { + entity: null, + distance: 0, + point: new Vector3(), + normal: new Vector3(), + component: null + }; + // The 4x4 region attachment centered at the origin: a ray down +z at (0,0) hits it. + const hitRay = new Ray(new Vector3(0, 0, 10), new Vector3(0, 0, -1)); + const missRay = new Ray(new Vector3(100, 100, 10), new Vector3(0, 0, -1)); + + expect((canvas as any)._raycast(hitRay, out)).to.be.false; + renderer.raycastEnabled = true; + expect((canvas as any)._raycast(hitRay, out)).to.be.true; + expect(out.component).to.equal(renderer); + expect((canvas as any)._raycast(missRay, out)).to.be.false; + canvasEntity.destroy(); + }); + + it("computes bounds from the skeleton for canvas culling", () => { + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + const bounds = renderer.bounds; + expect(bounds.max.x - bounds.min.x).to.be.closeTo(4, 1e-4); + expect(bounds.max.y - bounds.min.y).to.be.closeTo(4, 1e-4); + canvasEntity.destroy(); + }); + + it("clones under a canvas with a fresh skeleton/state and copied config", () => { + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + renderer.zSpacing = 0.01; + renderer.defaultConfig.animationName = "idle"; + + const cloneEntity = spineEntity.clone(); + canvasEntity.addChild(cloneEntity); + const cloneRenderer = cloneEntity.getComponent(SpineAnimationRenderer); + expect(cloneRenderer.skeleton).to.not.be.undefined; + expect(cloneRenderer.skeleton).to.not.equal(renderer.skeleton); + expect(cloneRenderer.skeleton.data).to.equal(renderer.skeleton.data); + expect(cloneRenderer.state).to.not.equal(renderer.state); + expect(cloneRenderer.zSpacing).to.equal(0.01); + expect(cloneRenderer.defaultConfig.animationName).to.equal("idle"); + expect(cloneRenderer.defaultConfig).to.not.equal(renderer.defaultConfig); + canvasEntity.destroy(); + }); + + it("destroy while hosted removes its cached materials and releases the primitive", () => { + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + const material = renderer.getMaterial() as any; + expect(material._cacheKey).to.be.a("string"); + expect(SpineAnimationRenderer._materialCacheMap.has(material._cacheKey)).to.be.true; + expect(() => spineEntity.destroy()).to.not.throw(); + expect(SpineAnimationRenderer._materialCacheMap.has(material._cacheKey)).to.be.false; + expect((renderer as any)._primitive).to.be.null; + canvasEntity.destroy(); + }); +}); From dd965d450676cbb70aa720126b5fed33359f8152 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Wed, 15 Jul 2026 20:08:08 +0800 Subject: [PATCH 2/8] fix(ui): reset element canvas state when their root canvas is demoted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _setIsRootCanvas(false) marked the demoted canvas itself dirty once per element instead of the element (present since #2375). The bulk clear below only empties the canvas's element list, leaving each element's _rootCanvas pointer, _indexInRootCanvas and dirty flag stale — and the new root's walk re-assignment is gated on that dirty flag, so re-homed elements kept pushing render elements into the demoted canvas's dead queue and disappeared. Canvas-hosted renderers rely on the same gate, so demotion re-homing now works for them too. Co-Authored-By: Claude Fable 5 --- packages/ui/src/component/UICanvas.ts | 5 +++- tests/src/ui/UICanvas.test.ts | 40 +++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index 7fe9cb60c7..c03fe8b7d9 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -656,7 +656,10 @@ export class UICanvas extends Component implements IElement { element._setIsRootCanvas(!rootCanvas); Utils.setRootCanvas(element, rootCanvas); } else { - Utils.setRootCanvasDirty(this); + // Reset the element's own canvas state (stale _rootCanvas/_indexInRootCanvas and + // the dirty flag the new root's walk re-assignment is gated on) — the bulk + // clear below only empties this canvas's list. + Utils.setRootCanvasDirty(element); Utils.setGroupDirty(element); } }); diff --git a/tests/src/ui/UICanvas.test.ts b/tests/src/ui/UICanvas.test.ts index 0519a1e98d..f02ee765af 100644 --- a/tests/src/ui/UICanvas.test.ts +++ b/tests/src/ui/UICanvas.test.ts @@ -1,7 +1,7 @@ import { Camera } from "@galacean/engine-core"; import { Vector2 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine-rhi-webgl"; -import { CanvasRenderMode, ResolutionAdaptationMode, UICanvas, UITransform } from "@galacean/engine-ui"; +import { CanvasRenderMode, Image, ResolutionAdaptationMode, UICanvas, UITransform } from "@galacean/engine-ui"; import { describe, expect, it } from "vitest"; describe("UICanvas", async () => { @@ -294,7 +294,7 @@ describe("UICanvas", async () => { // @ts-ignore expect(cloneCanvas._isRootCanvas).to.eq(true); - const cameraNeedClone = canvasEntity.createChild('camera').addComponent(Camera); + const cameraNeedClone = canvasEntity.createChild("camera").addComponent(Camera); rootCanvas.renderCamera = cameraNeedClone; const anoCloneEntity = canvasEntity.clone(); const anoCloneCanvas = anoCloneEntity.getComponent(UICanvas); @@ -352,4 +352,40 @@ describe("UICanvas", async () => { // @ts-ignore expect(rootCanvas._canDispatchEvent(camera2)).to.be.false; }); + it("re-homes elements to the new root canvas when theirs loses root status", () => { + const outerEntity = root.createChild("outer"); + const innerEntity = outerEntity.createChild("inner"); + const innerCanvas = innerEntity.addComponent(UICanvas); + const imageEntity = innerEntity.createChild("image"); + const image = imageEntity.addComponent(Image); + + // Collected by the inner canvas while it is the root. + // @ts-ignore + innerCanvas._getRenderers(); + // @ts-ignore + expect(image._getRootCanvas()).to.eq(innerCanvas); + // @ts-ignore + expect(innerCanvas._disorderedElements.length).to.be.greaterThan(0); + + // Enabling a canvas on an ancestor demotes the inner one. + const outerCanvas = outerEntity.addComponent(UICanvas); + // @ts-ignore + expect(innerCanvas._isRootCanvas).to.be.false; + // @ts-ignore + expect(outerCanvas._isRootCanvas).to.be.true; + // The element's canvas state must be reset (the walk's re-assignment is gated on the + // dirty flag), otherwise it keeps rendering into the demoted canvas's dead queue. + // @ts-ignore + expect(image._isRootCanvasDirty).to.be.true; + // @ts-ignore + expect(image._rootCanvas).to.be.null; + + // The new root adopts it on its next walk. + // @ts-ignore + outerCanvas._getRenderers(); + // @ts-ignore + expect(image._getRootCanvas()).to.eq(outerCanvas); + + outerEntity.destroy(); + }); }); From a169dcca7ecd6b27957454cc9ea757db8349a88a Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 16 Jul 2026 11:32:00 +0800 Subject: [PATCH 3/8] refactor(ui): keep the entity hierarchy-version mixin in the ui package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entity stays free of UI members: _uiHierarchyVersion/_updateUIHierarchyVersion return to the ui package's ApplyMixins extension. Hosted renderers call the mixin method optionally — a canvas implies the ui package is loaded, and without it there is nothing to notify. The version counter stays in core's UIElementUtils, which both sides read. Co-Authored-By: Claude Fable 5 --- packages/core/src/Entity.ts | 14 -------------- .../spine/src/renderer/SpineAnimationRenderer.ts | 14 ++++++-------- packages/ui/src/index.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 28323d38b5..5b0c929ac1 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -111,8 +111,6 @@ export class Entity extends EngineObject { /** @internal */ _isRoot: boolean = false; /** @internal */ - _uiHierarchyVersion: number = 0; - /** @internal */ _isActive: boolean = true; /** @internal */ _siblingIndex: number = -1; @@ -614,18 +612,6 @@ export class Entity extends EngineObject { this._modifyFlagManager?.removeListener(onChange); } - /** - * @internal - * Stamp the hierarchy version up the parent chain so root canvases detect that their - * subtree changed and rebuild their element list. - */ - _updateUIHierarchyVersion(version: number): void { - if (this._uiHierarchyVersion !== version) { - this._uiHierarchyVersion = version; - this.parent?._updateUIHierarchyVersion(version); - } - } - private _dispatchModify(flag: EntityModifyFlags, param?: any): void { this._modifyFlagManager?.dispatch(flag, param); } diff --git a/packages/spine/src/renderer/SpineAnimationRenderer.ts b/packages/spine/src/renderer/SpineAnimationRenderer.ts index c6e217a8b0..72f51c9c29 100644 --- a/packages/spine/src/renderer/SpineAnimationRenderer.ts +++ b/packages/spine/src/renderer/SpineAnimationRenderer.ts @@ -270,8 +270,9 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg const rootCanvas = UIElementUtils.searchRootCanvasInParents(this.entity); this._setHostedByUICanvas(!!rootCanvas, componentsManager, false); if (rootCanvas) { - // @ts-ignore - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + // The mixin method exists only when the ui package is loaded — without it there are + // no canvases to notify. + (this.entity as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); UIElementUtils.setRootCanvasDirty(this); UIElementUtils.setGroupDirty(this); } else { @@ -291,8 +292,7 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg // @ts-ignore this._overrideUpdate && componentsManager.removeOnUpdateRenderers(this); if (this._hostedByUICanvas) { - // @ts-ignore - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + (this.entity as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); } else { componentsManager.removeRenderer(this); } @@ -404,8 +404,7 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg UIElementUtils.setRootCanvasDirty(this); UIElementUtils.setGroupDirty(this); case EntityModifyFlags.Child: - // @ts-ignore - (param as Entity)._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + (param as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); break; default: break; @@ -595,8 +594,7 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg const componentsManager = this.scene._componentsManager; this._setHostedByUICanvas(hosted, componentsManager, true); if (hosted) { - // @ts-ignore - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + (this.entity as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); } else { // Re-arm whole-chain listeners (the canvas-scoped range no longer covers the new parents). UIElementUtils.setRootCanvas(this, null, this.entity); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9a0d4d0822..ea5b3a6b38 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,6 +3,7 @@ import { BlendOperation, CullMode, Engine, + Entity, Loader, Material, PipelineStage, @@ -45,6 +46,17 @@ export class EngineExtension { } } +export class EntityExtension { + _uiHierarchyVersion = 0; + _updateUIHierarchyVersion(version: number): void { + if (this._uiHierarchyVersion !== version) { + this._uiHierarchyVersion = version; + // @ts-ignore + this.parent?._updateUIHierarchyVersion(version); + } + } +} + declare module "@galacean/engine" { interface Engine { // @internal @@ -73,6 +85,7 @@ function ApplyMixins(derivedCtor: any, baseCtors: any[]): void { } ApplyMixins(Engine, [EngineExtension]); +ApplyMixins(Entity, [EntityExtension]); /** * Register GUI components for the editor. From 68a4409348c8768eb7259fe427f08a64544fe14f Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 16 Jul 2026 12:11:09 +0800 Subject: [PATCH 4/8] refactor(ui): unify the element contracts on the core IUIElement chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ui-local IElement/IGroupAble/IGraphics chain was a structural twin of the core protocol left over from the extraction; ui components now implement the core chain directly and the local interfaces are deleted: - IUIHostedRenderer renamed IUIRenderer — UIRenderer is its reference implementation (implements it with the _isUIRenderer marker), spine another - UICanvas implements IUIElement; UIGroup/UIInteractive implement IUIGroupAble; raycastPadding stays a UIRenderer member (canvas contract does not consume it) - the canvas walk gate collapses to the single marker check and CanvasRenderable simplifies to Renderer & IUIRenderer - rect-clip state is now applied by the canvas for every renderable in one place instead of UIRenderer self-applying and the canvas covering others Co-Authored-By: Claude Fable 5 --- packages/core/src/ui/IUIElement.ts | 15 ++++++------ packages/core/src/ui/index.ts | 2 +- .../src/renderer/SpineAnimationRenderer.ts | 10 ++++---- packages/ui/src/component/UICanvas.ts | 24 +++++++------------ packages/ui/src/component/UIGroup.ts | 8 +++---- packages/ui/src/component/UIRenderer.ts | 8 ++++--- .../component/interactive/UIInteractive.ts | 4 ++-- packages/ui/src/interface/IElement.ts | 15 ------------ packages/ui/src/interface/IGraphics.ts | 10 -------- packages/ui/src/interface/IGroupAble.ts | 17 ------------- 10 files changed, 34 insertions(+), 79 deletions(-) delete mode 100644 packages/ui/src/interface/IElement.ts delete mode 100644 packages/ui/src/interface/IGraphics.ts delete mode 100644 packages/ui/src/interface/IGroupAble.ts diff --git a/packages/core/src/ui/IUIElement.ts b/packages/core/src/ui/IUIElement.ts index 6fe8f619e5..337866c4a4 100644 --- a/packages/core/src/ui/IUIElement.ts +++ b/packages/core/src/ui/IUIElement.ts @@ -85,17 +85,18 @@ export interface IUIHitResult { } /** - * A renderer that is not a ui-package `UIRenderer` but can be hosted by a `UICanvas`: - * when placed under a root canvas it is collected, ordered, group-faded, rect-clipped and - * hit-tested by the canvas instead of rendering through the camera pipeline. + * A renderer a root `UICanvas` collects and drives: ordered with the other UI elements, + * group-faded, rect-clipped and hit-tested by the canvas. The ui package's `UIRenderer` is + * the reference implementation; any other `Renderer` (e.g. spine) can implement it to be + * hosted by a canvas instead of rendering through the camera pipeline. * * @remarks - * Implementers switch their own `ComponentsManager` renderer registration off while hosted - * and push their render elements into `IUICanvas._renderElements` from `_render`. + * Non-ui implementers switch their own `ComponentsManager` renderer registration off while + * hosted and push their render elements into `IUICanvas._renderElements` from `_render`. */ -export interface IUIHostedRenderer extends IUIGroupAble { +export interface IUIRenderer extends IUIGroupAble { /** Marker checked by the canvas walk. */ - _isUIHostedRenderer: boolean; + _isUIRenderer: boolean; raycastEnabled: boolean; _raycast(ray: Ray, out: IUIHitResult, distance: number): boolean; diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index 87a13a9372..fc37a1d663 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -1,4 +1,4 @@ export { EntityUIModifyFlags, GroupModifyFlags, RootCanvasModifyFlags } from "./IUIElement"; -export type { IUIElement, IUIGroup, IUIGroupAble, IUIHitResult, IUIHostedRenderer } from "./IUIElement"; +export type { IUIElement, IUIGroup, IUIGroupAble, IUIHitResult, IUIRenderer } from "./IUIElement"; export type { IUICanvas } from "./IUICanvas"; export { UIElementUtils } from "./UIElementUtils"; diff --git a/packages/spine/src/renderer/SpineAnimationRenderer.ts b/packages/spine/src/renderer/SpineAnimationRenderer.ts index 72f51c9c29..e67b587652 100644 --- a/packages/spine/src/renderer/SpineAnimationRenderer.ts +++ b/packages/spine/src/renderer/SpineAnimationRenderer.ts @@ -27,7 +27,7 @@ import { VertexElement, VertexElementFormat } from "@galacean/engine"; -import type { IUICanvas, IUIGroup, IUIHitResult, IUIHostedRenderer } from "@galacean/engine"; +import type { IUICanvas, IUIGroup, IUIHitResult, IUIRenderer } from "@galacean/engine"; import { SpineResource } from "../loader/SpineResource"; import { SpineMaterial } from "./SpineMaterial"; import { SpineBlendMode } from "../enums/SpineBlendMode"; @@ -40,11 +40,11 @@ import type { ISpineRenderTarget } from "../runtime/ISpineRenderTarget"; * * @remarks * Renders in world space through the camera pipeline, or — when placed under a root `UICanvas` — - * is hosted by the canvas (implements the engine's `IUIHostedRenderer` contract): collected and + * is hosted by the canvas (implements the engine's `IUIRenderer` contract): collected and * ordered with the other UI elements, faded by `UIGroup` alpha and clipped by `RectMask2D`. * The skeleton renders at the entity's transform scale in both spaces. */ -export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarget, IUIHostedRenderer { +export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarget, IUIRenderer { private static _positionVertexElement = new VertexElement("POSITION", 0, VertexElementFormat.Vector3, 0); private static _lightColorVertexElement = new VertexElement("LIGHT_COLOR", 12, VertexElementFormat.Vector4, 0); private static _uvVertexElement = new VertexElement("TEXCOORD_0", 28, VertexElementFormat.Vector2, 0); @@ -140,9 +140,9 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg new Vector3(-Infinity, -Infinity, -Infinity) ); - /** @internal Marker checked by the `UICanvas` walk (`IUIHostedRenderer`). */ + /** @internal Marker checked by the `UICanvas` walk (`IUIRenderer`). */ @ignoreClone - _isUIHostedRenderer = true; + _isUIRenderer = true; /** @internal */ @ignoreClone _rootCanvas: IUICanvas = null; diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index c03fe8b7d9..9a805f8103 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -23,13 +23,11 @@ import { ignoreClone, CloneUtils } from "@galacean/engine"; -import type { IUIGroupAble, IUIHostedRenderer } from "@galacean/engine"; +import type { IUIElement, IUIGroupAble, IUIRenderer } from "@galacean/engine"; import { Utils } from "../Utils"; import { CanvasRenderMode } from "../enums/CanvasRenderMode"; import { ResolutionAdaptationMode } from "../enums/ResolutionAdaptationMode"; import { UIHitResult } from "../input/UIHitResult"; -import { IElement } from "../interface/IElement"; -import { IGroupAble } from "../interface/IGroupAble"; import { RectMask2D } from "./advanced/RectMask2D"; import { UIGroup } from "./UIGroup"; import { UIRenderer } from "./UIRenderer"; @@ -41,7 +39,7 @@ import { UIInteractive } from "./interactive/UIInteractive"; * handling rendering and events based on it. */ @dependentComponents(UITransform, DependentMode.AutoAdd) -export class UICanvas extends Component implements IElement { +export class UICanvas extends Component implements IUIElement { private static _tempGroupAbleList: IUIGroupAble[] = []; private static _tempRectMaskList: RectMask2D[] = []; private static _tempVec3: Vector3 = new Vector3(); @@ -79,7 +77,7 @@ export class UICanvas extends Component implements IElement { _realRenderMode: number = CanvasRealRenderMode.None; /** @internal */ @ignoreClone - _disorderedElements: DisorderedArray = new DisorderedArray(); + _disorderedElements: DisorderedArray = new DisorderedArray(); @ignoreClone private _renderMode = CanvasRenderMode.WorldSpace; @@ -343,11 +341,7 @@ export class UICanvas extends Component implements IElement { break; } } - if (!(renderer instanceof UIRenderer)) { - // Hosted renderers don't run UIRenderer's prepare path, so the canvas applies - // their rect-clip state before rendering. - Utils.updateRectMaskClipState(renderer); - } + Utils.updateRectMaskClipState(renderer); // @ts-ignore renderer._prepareRender(context); renderer._renderFrameCount = frameCount; @@ -527,7 +521,7 @@ export class UICanvas extends Component implements IElement { for (let i = 0, n = components.length; i < n; i++) { const component = components[i]; if (!component.enabled) continue; - if (component instanceof UIRenderer || (component as unknown as IUIHostedRenderer)._isUIHostedRenderer) { + if ((component as unknown as IUIRenderer)._isUIRenderer) { const renderable = component as unknown as CanvasRenderable; renderers[depth] = renderable; ++depth; @@ -650,7 +644,7 @@ export class UICanvas extends Component implements IElement { this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); } else { const { _disorderedElements: disorderedElements } = this; - disorderedElements.forEach((element: IElement) => { + disorderedElements.forEach((element: IUIElement) => { if (element instanceof UICanvas) { const rootCanvas = Utils.searchRootCanvasInParents(element); element._setIsRootCanvas(!rootCanvas); @@ -660,7 +654,7 @@ export class UICanvas extends Component implements IElement { // the dirty flag the new root's walk re-assignment is gated on) — the bulk // clear below only empties this canvas's list. Utils.setRootCanvasDirty(element); - Utils.setGroupDirty(element); + Utils.setGroupDirty(element); } }); disorderedElements.length = 0; @@ -750,6 +744,6 @@ export { RootCanvasModifyFlags } from "@galacean/engine"; /** * A renderer a root canvas collects and drives: either a ui `UIRenderer` or any engine - * `Renderer` implementing the core `IUIHostedRenderer` hosting contract. + * `Renderer` implementing the core `IUIRenderer` contract. */ -export type CanvasRenderable = UIRenderer | (Renderer & IUIHostedRenderer); +export type CanvasRenderable = Renderer & IUIRenderer; diff --git a/packages/ui/src/component/UIGroup.ts b/packages/ui/src/component/UIGroup.ts index 6ca1114ac5..a61abb90a7 100644 --- a/packages/ui/src/component/UIGroup.ts +++ b/packages/ui/src/component/UIGroup.ts @@ -8,10 +8,10 @@ import { ignoreClone } from "@galacean/engine"; import { Utils } from "../Utils"; -import { IGroupAble } from "../interface/IGroupAble"; +import type { IUIGroupAble } from "@galacean/engine"; import { EntityUIModifyFlags, UICanvas } from "./UICanvas"; -export class UIGroup extends Component implements IGroupAble { +export class UIGroup extends Component implements IUIGroupAble { /** @internal Marker letting core-level code identify a group without a ui-package dependency. */ _isUIGroup = true; /** @internal */ @@ -26,7 +26,7 @@ export class UIGroup extends Component implements IGroupAble { _rootCanvas: UICanvas; /** @internal */ @ignoreClone - _disorderedElements: DisorderedArray = new DisorderedArray(); + _disorderedElements: DisorderedArray = new DisorderedArray(); /** @internal */ @ignoreClone @@ -154,7 +154,7 @@ export class UIGroup extends Component implements IGroupAble { Utils.cleanRootCanvas(this); Utils.cleanGroup(this); const disorderedElements = this._disorderedElements; - disorderedElements.forEach((element: IGroupAble) => { + disorderedElements.forEach((element: IUIGroupAble) => { Utils.setGroupDirty(element); }); disorderedElements.length = 0; diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index 35bf8c874a..fd96996221 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -24,14 +24,14 @@ import { } from "@galacean/engine"; import { Utils } from "../Utils"; import { UIHitResult } from "../input/UIHitResult"; -import { IGraphics } from "../interface/IGraphics"; +import type { IUIRenderer } from "@galacean/engine"; import { RectMask2D } from "./advanced/RectMask2D"; import { EntityUIModifyFlags, UICanvas } from "./UICanvas"; import { GroupModifyFlags, UIGroup } from "./UIGroup"; import { UITransform } from "./UITransform"; @dependentComponents(UITransform, DependentMode.AutoAdd) -export class UIRenderer extends Renderer implements IGraphics { +export class UIRenderer extends Renderer implements IUIRenderer { /** @internal */ static _tempVec20: Vector2 = new Vector2(); /** @internal */ @@ -47,6 +47,9 @@ export class UIRenderer extends Renderer implements IGraphics { /** @internal */ static _tempRect: Vector4 = new Vector4(); + /** @internal Marker checked by the `UICanvas` walk (`IUIRenderer`). */ + @ignoreClone + _isUIRenderer = true; /** * Custom boundary for raycast detection. * @remarks this is based on `this.entity.transform`. @@ -186,7 +189,6 @@ export class UIRenderer extends Renderer implements IGraphics { this._update(context); } - Utils.updateRectMaskClipState(this); this._render(context); // union camera global macro and renderer macro. diff --git a/packages/ui/src/component/interactive/UIInteractive.ts b/packages/ui/src/component/interactive/UIInteractive.ts index 6811819585..33e19bfa97 100644 --- a/packages/ui/src/component/interactive/UIInteractive.ts +++ b/packages/ui/src/component/interactive/UIInteractive.ts @@ -7,9 +7,9 @@ import { deepClone, ignoreClone } from "@galacean/engine"; +import type { IUIGroupAble } from "@galacean/engine"; import { UIGroup } from "../.."; import { Utils } from "../../Utils"; -import { IGroupAble } from "../../interface/IGroupAble"; import { EntityUIModifyFlags, UICanvas } from "../UICanvas"; import { GroupModifyFlags } from "../UIGroup"; import { Transition } from "./transition/Transition"; @@ -17,7 +17,7 @@ import { Transition } from "./transition/Transition"; /** * Interactive component. */ -export class UIInteractive extends Script implements IGroupAble { +export class UIInteractive extends Script implements IUIGroupAble { /** @internal */ _rootCanvas: UICanvas; /** @internal */ diff --git a/packages/ui/src/interface/IElement.ts b/packages/ui/src/interface/IElement.ts deleted file mode 100644 index 2e85507cc4..0000000000 --- a/packages/ui/src/interface/IElement.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Entity } from "@galacean/engine"; -import { UICanvas } from ".."; -import { RootCanvasModifyFlags } from "../component/UICanvas"; - -export interface IElement { - entity: Entity; - _rootCanvas: UICanvas; - _indexInRootCanvas: number; - _rootCanvasListeningEntities: Entity[]; - _isRootCanvasDirty: boolean; - - _getRootCanvas(): UICanvas; - _rootCanvasListener: (flag: number, param?: any) => void; - _onRootCanvasModify?(flag: RootCanvasModifyFlags): void; -} diff --git a/packages/ui/src/interface/IGraphics.ts b/packages/ui/src/interface/IGraphics.ts deleted file mode 100644 index 8bdeda2a00..0000000000 --- a/packages/ui/src/interface/IGraphics.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Ray, Vector4 } from "@galacean/engine"; -import { UIHitResult } from "../input/UIHitResult"; -import { IGroupAble } from "./IGroupAble"; - -export interface IGraphics extends IGroupAble { - raycastEnabled: boolean; - raycastPadding: Vector4; - - _raycast(ray: Ray, out: UIHitResult, distance: number): boolean; -} diff --git a/packages/ui/src/interface/IGroupAble.ts b/packages/ui/src/interface/IGroupAble.ts deleted file mode 100644 index 0e6bfd2923..0000000000 --- a/packages/ui/src/interface/IGroupAble.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Entity } from "@galacean/engine"; -import { GroupModifyFlags, UIGroup } from "../component/UIGroup"; -import { IElement } from "./IElement"; - -export interface IGroupAble extends IElement { - _group: UIGroup; - _indexInGroup: number; - _groupListeningEntities: Entity[]; - _isGroupDirty: boolean; - - _globalAlpha?: number; - _globalInteractive?: boolean; - - _getGroup(): UIGroup; - _onGroupModify(flag: GroupModifyFlags, isPass?: boolean): void; - _groupListener(flag: number): void; -} From 63ea872865b55a84b3b6084343e95de518e4a3cb Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 16 Jul 2026 12:23:40 +0800 Subject: [PATCH 5/8] refactor(ui): keep the element bookkeeping in the ui package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core now exposes only the declarative UI contract — the IUIElement/ IUIGroupAble/IUIRenderer chain, IUICanvas/IUIGroup and the flag enums that appear in its signatures — with zero behavior or state: - UIElementUtils is dissolved: the registration/listener machinery returns to ui's Utils and the hierarchy counter to UICanvas._hierarchyCounter - the ui Entity mixin's _updateUIHierarchyVersion now defaults to the current counter, so hosted renderers stamp with an optional no-arg call instead of reaching into ui internals - SpineAnimationRenderer implements its side of the protocol itself against the typed core interfaces (canvas/group ancestor search via the _isRootCanvas/_isUIGroup markers, parent-chain listeners over core Entity APIs, deregistration through IUICanvas/IUIGroup._disorderedElements) Co-Authored-By: Claude Fable 5 --- packages/core/src/ui/IUIElement.ts | 2 +- packages/core/src/ui/UIElementUtils.ts | 172 ----------------- packages/core/src/ui/index.ts | 1 - .../src/renderer/SpineAnimationRenderer.ts | 181 ++++++++++++++++-- packages/ui/src/Utils.ts | 132 +++++++++++-- packages/ui/src/component/UICanvas.ts | 7 +- packages/ui/src/component/UIRenderer.ts | 7 +- .../ui/src/component/advanced/RectMask2D.ts | 5 +- packages/ui/src/index.ts | 7 +- 9 files changed, 295 insertions(+), 219 deletions(-) delete mode 100644 packages/core/src/ui/UIElementUtils.ts diff --git a/packages/core/src/ui/IUIElement.ts b/packages/core/src/ui/IUIElement.ts index 337866c4a4..45b65288d4 100644 --- a/packages/core/src/ui/IUIElement.ts +++ b/packages/core/src/ui/IUIElement.ts @@ -31,7 +31,7 @@ export enum GroupModifyFlags { /** * An element owned by a root UI canvas: tracks its canvas and listens to hierarchy changes. - * Underscore members are engine-internal plumbing maintained by the canvas and `UIElementUtils`. + * Underscore members are engine-internal plumbing maintained by the ui package's `Utils`. */ export interface IUIElement { entity: Entity; diff --git a/packages/core/src/ui/UIElementUtils.ts b/packages/core/src/ui/UIElementUtils.ts deleted file mode 100644 index 072e3a0f14..0000000000 --- a/packages/core/src/ui/UIElementUtils.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Entity } from "../Entity"; -import { GroupModifyFlags, IUIElement, IUIGroup, IUIGroupAble, RootCanvasModifyFlags } from "./IUIElement"; -import { IUICanvas } from "./IUICanvas"; - -/** - * Root-canvas / group ownership bookkeeping for UI elements and canvas-hosted renderers. - * The ui package's `Utils` delegates here; renderer packages (e.g. spine) call it directly - * so they can be hosted by a `UICanvas` without depending on the ui package. - */ -export class UIElementUtils { - /** - * Monotonic version used by root canvases to detect hierarchy changes: elements stamp it - * onto their entity chain on enable/disable/move, canvases bump it after each rebuild. - * Starts above the Entity field default so the first stamp always propagates. - */ - static _hierarchyCounter = 1; - - static setRootCanvasDirty(element: IUIElement): void { - if (element._isRootCanvasDirty) return; - element._isRootCanvasDirty = true; - this._registerRootCanvas(element, null); - element._onRootCanvasModify?.(RootCanvasModifyFlags.All); - } - - /** - * @param listenerStartEntity - The entity the parent-chain listeners start from - * (the element's own entity; a nested canvas element starts from its parent). - */ - static setRootCanvas(element: IUIElement, rootCanvas: IUICanvas, listenerStartEntity: Entity): void { - element._isRootCanvasDirty = false; - this._registerRootCanvas(element, rootCanvas); - const toEntity = rootCanvas?.entity.parent ?? null; - this._registerListener( - listenerStartEntity, - toEntity, - element._rootCanvasListener, - element._rootCanvasListeningEntities - ); - } - - static cleanRootCanvas(element: IUIElement): void { - this._registerRootCanvas(element, null); - this._unRegisterListener(element._rootCanvasListener, element._rootCanvasListeningEntities); - } - - static searchRootCanvasInParents(startEntity: Entity): IUICanvas { - let entity = startEntity; - while (entity) { - // @ts-ignore - const components = entity._components; - for (let i = 0, n = components.length; i < n; i++) { - const component = components[i]; - if (component.enabled && (component as unknown as IUICanvas)._isRootCanvas === true) { - return component as unknown as IUICanvas; - } - } - entity = entity.parent; - } - return null; - } - - static setGroupDirty(element: IUIGroupAble): void { - if (element._isGroupDirty) return; - element._isGroupDirty = true; - this._registerGroup(element, null); - element._onGroupModify(GroupModifyFlags.All); - } - - /** - * @param listenerStartEntity - The entity the group listeners start from - * (the element's own entity; a nested group element starts from its parent). - */ - static setGroup(element: IUIGroupAble, group: IUIGroup, listenerStartEntity: Entity): void { - element._isGroupDirty = false; - this._registerGroup(element, group); - const rootCanvas = element._getRootCanvas(); - if (rootCanvas) { - const toEntity = group?.entity ?? rootCanvas.entity.parent; - this._registerListener(listenerStartEntity, toEntity, element._groupListener, element._groupListeningEntities); - } else { - this._unRegisterListener(element._groupListener, element._groupListeningEntities); - } - } - - static cleanGroup(element: IUIGroupAble): void { - this._registerGroup(element, null); - this._unRegisterListener(element._groupListener, element._groupListeningEntities); - } - - static searchGroupInParents(startEntity: Entity, rootCanvas: IUICanvas): IUIGroup { - if (!rootCanvas) return null; - let entity = startEntity; - const rootCanvasParent = rootCanvas.entity.parent; - while (entity && entity !== rootCanvasParent) { - // @ts-ignore - const components = entity._components; - for (let i = 0, n = components.length; i < n; i++) { - const component = components[i]; - if (component.enabled && (component as unknown as IUIGroup)._isUIGroup === true) { - return component as unknown as IUIGroup; - } - } - entity = entity.parent; - } - return null; - } - - private static _registerRootCanvas(element: IUIElement, canvas: IUICanvas): void { - const preCanvas = element._rootCanvas; - if (preCanvas !== canvas) { - if (preCanvas) { - const replaced = preCanvas._disorderedElements.deleteByIndex(element._indexInRootCanvas); - replaced && (replaced._indexInRootCanvas = element._indexInRootCanvas); - element._indexInRootCanvas = -1; - } - if (canvas) { - const disorderedElements = canvas._disorderedElements; - element._indexInRootCanvas = disorderedElements.length; - disorderedElements.add(element); - } - element._rootCanvas = canvas; - } - } - - private static _registerGroup(element: IUIGroupAble, group: IUIGroup): void { - const preGroup = element._group; - if (preGroup !== group) { - if (preGroup) { - const replaced = preGroup._disorderedElements.deleteByIndex(element._indexInGroup); - replaced && (replaced._indexInGroup = element._indexInGroup); - element._indexInGroup = -1; - } - if (group) { - const disorderedElements = group._disorderedElements; - element._indexInGroup = disorderedElements.length; - disorderedElements.add(element); - } - element._group = group; - element._onGroupModify(GroupModifyFlags.All); - } - } - - private static _registerListener( - entity: Entity, - root: Entity, - listener: (flag: number, param?: any) => void, - listeningEntities: Entity[] - ): void { - let count = 0; - while (entity && entity !== root) { - const preEntity = listeningEntities[count]; - if (preEntity !== entity) { - // @ts-ignore - preEntity?._unRegisterModifyListener(listener); - listeningEntities[count] = entity; - // @ts-ignore - entity._registerModifyListener(listener); - } - entity = entity.parent; - count++; - } - listeningEntities.length = count; - } - - private static _unRegisterListener(listener: (flag: number, param?: any) => void, listeningEntities: Entity[]): void { - for (let i = 0, n = listeningEntities.length; i < n; i++) { - // @ts-ignore - listeningEntities[i]._unRegisterModifyListener(listener); - } - listeningEntities.length = 0; - } -} diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index fc37a1d663..d1fb8e3297 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -1,4 +1,3 @@ export { EntityUIModifyFlags, GroupModifyFlags, RootCanvasModifyFlags } from "./IUIElement"; export type { IUIElement, IUIGroup, IUIGroupAble, IUIHitResult, IUIRenderer } from "./IUIElement"; export type { IUICanvas } from "./IUICanvas"; -export { UIElementUtils } from "./UIElementUtils"; diff --git a/packages/spine/src/renderer/SpineAnimationRenderer.ts b/packages/spine/src/renderer/SpineAnimationRenderer.ts index e67b587652..055747c29f 100644 --- a/packages/spine/src/renderer/SpineAnimationRenderer.ts +++ b/packages/spine/src/renderer/SpineAnimationRenderer.ts @@ -20,7 +20,6 @@ import { ShaderProperty, SubPrimitive, Texture2D, - UIElementUtils, Vector3, Vector4, VertexBufferBinding, @@ -267,18 +266,18 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg const componentsManager = this.scene._componentsManager; // @ts-ignore this._overrideUpdate && componentsManager.addOnUpdateRenderers(this); - const rootCanvas = UIElementUtils.searchRootCanvasInParents(this.entity); + const rootCanvas = this._searchRootCanvasInParents(); this._setHostedByUICanvas(!!rootCanvas, componentsManager, false); if (rootCanvas) { // The mixin method exists only when the ui package is loaded — without it there are - // no canvases to notify. - (this.entity as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); - UIElementUtils.setRootCanvasDirty(this); - UIElementUtils.setGroupDirty(this); + // no canvases to notify. It stamps the current hierarchy counter by default. + (this.entity as any)._updateUIHierarchyVersion?.(); + this._setRootCanvasDirty(); + this._setGroupDirty(); } else { componentsManager.addRenderer(this); // Listen to the whole parent chain so moving under a canvas re-homes the renderer. - UIElementUtils.setRootCanvas(this, null, this.entity); + this._registerListeners(this.entity, null, this._rootCanvasListener, this._rootCanvasListeningEntities); } } @@ -292,12 +291,12 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg // @ts-ignore this._overrideUpdate && componentsManager.removeOnUpdateRenderers(this); if (this._hostedByUICanvas) { - (this.entity as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); + (this.entity as any)._updateUIHierarchyVersion?.(); } else { componentsManager.removeRenderer(this); } - UIElementUtils.cleanRootCanvas(this); - UIElementUtils.cleanGroup(this); + this._cleanRootCanvas(); + this._cleanGroup(); } /** @@ -369,7 +368,15 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg */ _getRootCanvas(): IUICanvas { if (this._isRootCanvasDirty) { - UIElementUtils.setRootCanvas(this, UIElementUtils.searchRootCanvasInParents(this.entity), this.entity); + this._isRootCanvasDirty = false; + const rootCanvas = this._searchRootCanvasInParents(); + this._registerRootCanvas(rootCanvas); + this._registerListeners( + this.entity, + rootCanvas?.entity.parent ?? null, + this._rootCanvasListener, + this._rootCanvasListeningEntities + ); } return this._rootCanvas; } @@ -379,9 +386,20 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg */ _getGroup(): IUIGroup { if (this._isGroupDirty) { + this._isGroupDirty = false; const rootCanvas = this._getRootCanvas(); - const group = rootCanvas ? UIElementUtils.searchGroupInParents(this.entity, rootCanvas) : null; - UIElementUtils.setGroup(this, group, this.entity); + const group = rootCanvas ? this._searchGroupInParents(rootCanvas) : null; + this._registerGroup(group); + if (rootCanvas) { + this._registerListeners( + this.entity, + group?.entity ?? rootCanvas.entity.parent, + this._groupListener, + this._groupListeningEntities + ); + } else { + this._unRegisterListeners(this._groupListener, this._groupListeningEntities); + } } return this._group; } @@ -401,10 +419,10 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg switch (flag) { case EntityModifyFlags.Parent: this._refreshHosting(); - UIElementUtils.setRootCanvasDirty(this); - UIElementUtils.setGroupDirty(this); + this._setRootCanvasDirty(); + this._setGroupDirty(); case EntityModifyFlags.Child: - (param as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); + (param as any)._updateUIHierarchyVersion?.(); break; default: break; @@ -417,7 +435,7 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg @ignoreClone _groupListener(flag: number): void { if (flag === EntityModifyFlags.Parent || flag === EntityUIModifyFlags.GroupEnableInScene) { - UIElementUtils.setGroupDirty(this); + this._setGroupDirty(); } } @@ -587,23 +605,144 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg } private _refreshHosting(): void { - const rootCanvas = UIElementUtils.searchRootCanvasInParents(this.entity); + const rootCanvas = this._searchRootCanvasInParents(); const hosted = !!rootCanvas; if (hosted !== this._hostedByUICanvas) { // @ts-ignore const componentsManager = this.scene._componentsManager; this._setHostedByUICanvas(hosted, componentsManager, true); if (hosted) { - (this.entity as any)._updateUIHierarchyVersion?.(UIElementUtils._hierarchyCounter); + (this.entity as any)._updateUIHierarchyVersion?.(); } else { // Re-arm whole-chain listeners (the canvas-scoped range no longer covers the new parents). - UIElementUtils.setRootCanvas(this, null, this.entity); - UIElementUtils.cleanGroup(this); + this._registerRootCanvas(null); + this._isRootCanvasDirty = false; + this._registerListeners(this.entity, null, this._rootCanvasListener, this._rootCanvasListeningEntities); + this._cleanGroup(); this._rectMasks.length = 0; } } } + private _searchRootCanvasInParents(): IUICanvas { + let entity = this.entity; + while (entity) { + // @ts-ignore + const components = entity._components; + for (let i = 0, n = components.length; i < n; i++) { + const component = components[i]; + if (component.enabled && (component as unknown as IUICanvas)._isRootCanvas === true) { + return component as unknown as IUICanvas; + } + } + entity = entity.parent; + } + return null; + } + + private _searchGroupInParents(rootCanvas: IUICanvas): IUIGroup { + let entity = this.entity; + const rootCanvasParent = rootCanvas.entity.parent; + while (entity && entity !== rootCanvasParent) { + // @ts-ignore + const components = entity._components; + for (let i = 0, n = components.length; i < n; i++) { + const component = components[i]; + if (component.enabled && (component as unknown as IUIGroup)._isUIGroup === true) { + return component as unknown as IUIGroup; + } + } + entity = entity.parent; + } + return null; + } + + private _setRootCanvasDirty(): void { + if (this._isRootCanvasDirty) return; + this._isRootCanvasDirty = true; + this._registerRootCanvas(null); + } + + private _setGroupDirty(): void { + if (this._isGroupDirty) return; + this._isGroupDirty = true; + this._registerGroup(null); + } + + private _cleanRootCanvas(): void { + this._registerRootCanvas(null); + this._unRegisterListeners(this._rootCanvasListener, this._rootCanvasListeningEntities); + } + + private _cleanGroup(): void { + this._registerGroup(null); + this._unRegisterListeners(this._groupListener, this._groupListeningEntities); + } + + private _registerRootCanvas(canvas: IUICanvas): void { + const preCanvas = this._rootCanvas; + if (preCanvas !== canvas) { + if (preCanvas) { + const replaced = preCanvas._disorderedElements.deleteByIndex(this._indexInRootCanvas); + replaced && (replaced._indexInRootCanvas = this._indexInRootCanvas); + this._indexInRootCanvas = -1; + } + if (canvas) { + const disorderedElements = canvas._disorderedElements; + this._indexInRootCanvas = disorderedElements.length; + disorderedElements.add(this); + } + this._rootCanvas = canvas; + } + } + + private _registerGroup(group: IUIGroup): void { + const preGroup = this._group; + if (preGroup !== group) { + if (preGroup) { + const replaced = preGroup._disorderedElements.deleteByIndex(this._indexInGroup); + replaced && (replaced._indexInGroup = this._indexInGroup); + this._indexInGroup = -1; + } + if (group) { + const disorderedElements = group._disorderedElements; + this._indexInGroup = disorderedElements.length; + disorderedElements.add(this); + } + this._group = group; + } + } + + private _registerListeners( + entity: Entity, + root: Entity, + listener: (flag: number, param?: any) => void, + listeningEntities: Entity[] + ): void { + let count = 0; + while (entity && entity !== root) { + const preEntity = listeningEntities[count]; + if (preEntity !== entity) { + // @ts-ignore + preEntity?._unRegisterModifyListener(listener); + listeningEntities[count] = entity; + // @ts-ignore + entity._registerModifyListener(listener); + } + entity = entity.parent; + count++; + } + listeningEntities.length = count; + } + + private _unRegisterListeners(listener: (flag: number, param?: any) => void, listeningEntities: Entity[]): void { + for (let i = 0, n = listeningEntities.length; i < n; i++) { + // @ts-ignore + listeningEntities[i]._unRegisterModifyListener(listener); + } + listeningEntities.length = 0; + } + private _clearMaterialCache(): void { const materialCache = SpineAnimationRenderer._materialCacheMap; const materials = this._materials; diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index 93ffd28569..31af29e95e 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -1,11 +1,12 @@ import { Entity, + GroupModifyFlags, Matrix, Plane, Ray, + RootCanvasModifyFlags, ShaderData, ShaderProperty, - UIElementUtils, Vector2, Vector3, Vector4 @@ -97,41 +98,148 @@ export class Utils { } static setRootCanvasDirty(element: IUIElement): void { - UIElementUtils.setRootCanvasDirty(element); + if (element._isRootCanvasDirty) return; + element._isRootCanvasDirty = true; + this._registerRootCanvas(element, null); + element._onRootCanvasModify?.(RootCanvasModifyFlags.All); } static setRootCanvas(element: IUIElement, rootCanvas: UICanvas): void { + element._isRootCanvasDirty = false; + this._registerRootCanvas(element, rootCanvas); const fromEntity = element instanceof UICanvas ? element.entity.parent : element.entity; - UIElementUtils.setRootCanvas(element, rootCanvas, fromEntity); + const toEntity = rootCanvas?.entity.parent ?? null; + this._registerListener(fromEntity, toEntity, element._rootCanvasListener, element._rootCanvasListeningEntities); } static cleanRootCanvas(element: IUIElement): void { - UIElementUtils.cleanRootCanvas(element); + this._registerRootCanvas(element, null); + this._unRegisterListener(element._rootCanvasListener, element._rootCanvasListeningEntities); } static searchRootCanvasInParents(element: IUIElement): UICanvas { - const startEntity = element instanceof UICanvas ? element.entity.parent : element.entity; - return UIElementUtils.searchRootCanvasInParents(startEntity); + let entity = element instanceof UICanvas ? element.entity.parent : element.entity; + while (entity) { + // @ts-ignore + const components = entity._components; + for (let i = 0, n = components.length; i < n; i++) { + const component = components[i]; + if (component.enabled && component instanceof UICanvas && component._isRootCanvas) { + return component; + } + } + entity = entity.parent; + } + return null; } static setGroupDirty(element: IUIGroupAble): void { - UIElementUtils.setGroupDirty(element); + if (element._isGroupDirty) return; + element._isGroupDirty = true; + this._registerGroup(element, null); + element._onGroupModify(GroupModifyFlags.All); } static setGroup(element: IUIGroupAble, group: UIGroup): void { - const fromEntity = element instanceof UIGroup ? element.entity.parent : element.entity; - UIElementUtils.setGroup(element, group, fromEntity); + element._isGroupDirty = false; + this._registerGroup(element, group); + const rootCanvas = element._getRootCanvas(); + if (rootCanvas) { + const fromEntity = element instanceof UIGroup ? element.entity.parent : element.entity; + const toEntity = group?.entity ?? rootCanvas.entity.parent; + this._registerListener(fromEntity, toEntity, element._groupListener, element._groupListeningEntities); + } else { + this._unRegisterListener(element._groupListener, element._groupListeningEntities); + } } static cleanGroup(element: IUIGroupAble): void { - UIElementUtils.cleanGroup(element); + this._registerGroup(element, null); + this._unRegisterListener(element._groupListener, element._groupListeningEntities); } static searchGroupInParents(element: IUIGroupAble): UIGroup { const rootCanvas = element._getRootCanvas(); if (!rootCanvas) return null; - const startEntity = element instanceof UIGroup ? element.entity.parent : element.entity; - return UIElementUtils.searchGroupInParents(startEntity, rootCanvas); + let entity = element instanceof UIGroup ? element.entity.parent : element.entity; + const rootCanvasParent = rootCanvas.entity.parent; + while (entity && entity !== rootCanvasParent) { + // @ts-ignore + const components = entity._components; + for (let i = 0, n = components.length; i < n; i++) { + const component = components[i]; + if (component.enabled && component instanceof UIGroup) { + return component; + } + } + entity = entity.parent; + } + return null; + } + + private static _registerRootCanvas(element: IUIElement, canvas: UICanvas): void { + const preCanvas = element._rootCanvas; + if (preCanvas !== canvas) { + if (preCanvas) { + const replaced = preCanvas._disorderedElements.deleteByIndex(element._indexInRootCanvas); + replaced && (replaced._indexInRootCanvas = element._indexInRootCanvas); + element._indexInRootCanvas = -1; + } + if (canvas) { + const disorderedElements = canvas._disorderedElements; + element._indexInRootCanvas = disorderedElements.length; + disorderedElements.add(element); + } + element._rootCanvas = canvas; + } + } + + private static _registerGroup(element: IUIGroupAble, group: UIGroup): void { + const preGroup = element._group; + if (preGroup !== group) { + if (preGroup) { + const replaced = preGroup._disorderedElements.deleteByIndex(element._indexInGroup); + replaced && (replaced._indexInGroup = element._indexInGroup); + element._indexInGroup = -1; + } + if (group) { + const disorderedElements = group._disorderedElements; + element._indexInGroup = disorderedElements.length; + disorderedElements.add(element); + } + element._group = group; + element._onGroupModify(GroupModifyFlags.All); + } + } + + private static _registerListener( + entity: Entity, + root: Entity, + listener: (flag: number, param?: any) => void, + listeningEntities: Entity[] + ): void { + let count = 0; + while (entity && entity !== root) { + const preEntity = listeningEntities[count]; + if (preEntity !== entity) { + // @ts-ignore + preEntity?._unRegisterModifyListener(listener); + listeningEntities[count] = entity; + // @ts-ignore + entity._registerModifyListener(listener); + } + entity = entity.parent; + count++; + } + listeningEntities.length = count; + } + + private static _unRegisterListener(listener: (flag: number, param?: any) => void, listeningEntities: Entity[]): void { + for (let i = 0, n = listeningEntities.length; i < n; i++) { + // @ts-ignore + listeningEntities[i]._unRegisterModifyListener(listener); + } + listeningEntities.length = 0; } /** diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index 9a805f8103..c03707e561 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -14,7 +14,6 @@ import { Ray, Renderer, RootCanvasModifyFlags, - UIElementUtils, Vector2, Vector3, assignmentClone, @@ -40,6 +39,8 @@ import { UIInteractive } from "./interactive/UIInteractive"; */ @dependentComponents(UITransform, DependentMode.AutoAdd) export class UICanvas extends Component implements IUIElement { + /** @internal */ + static _hierarchyCounter: number = 1; private static _tempGroupAbleList: IUIGroupAble[] = []; private static _tempRectMaskList: RectMask2D[] = []; private static _tempVec3: Vector3 = new Vector3(); @@ -426,7 +427,7 @@ export class UICanvas extends Component implements IUIElement { renderers.length = this._walk(this.entity, renderers, 0, null, 0); UICanvas._tempGroupAbleList.length = 0; this._hierarchyVersion = uiHierarchyVersion; - ++UIElementUtils._hierarchyCounter; + ++UICanvas._hierarchyCounter; } return renderers; } @@ -641,7 +642,7 @@ export class UICanvas extends Component implements IUIElement { this._updateCameraObserver(); this._setRealRenderMode(this._getRealRenderMode()); if (isRootCanvas) { - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); } else { const { _disorderedElements: disorderedElements } = this; disorderedElements.forEach((element: IUIElement) => { diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index fd96996221..d019438ead 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -13,7 +13,6 @@ import { ShaderProperty, SpriteMaskInteraction, SpriteMaskLayer, - UIElementUtils, Vector3, Vector4, assignmentClone, @@ -205,7 +204,7 @@ export class UIRenderer extends Renderer implements IUIRenderer { override _onEnableInScene(): void { // @ts-ignore this._overrideUpdate && this.scene._componentsManager.addOnUpdateRenderers(this); - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); Utils.setRootCanvasDirty(this); Utils.setGroupDirty(this); } @@ -214,7 +213,7 @@ export class UIRenderer extends Renderer implements IUIRenderer { override _onDisableInScene(): void { // @ts-ignore this._overrideUpdate && this.scene._componentsManager.removeOnUpdateRenderers(this); - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); Utils.cleanRootCanvas(this); Utils.cleanGroup(this); } @@ -262,7 +261,7 @@ export class UIRenderer extends Renderer implements IUIRenderer { Utils.setRootCanvasDirty(this); Utils.setGroupDirty(this); case EntityModifyFlags.Child: - entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); break; default: break; diff --git a/packages/ui/src/component/advanced/RectMask2D.ts b/packages/ui/src/component/advanced/RectMask2D.ts index b4350d5b33..9cba135e84 100644 --- a/packages/ui/src/component/advanced/RectMask2D.ts +++ b/packages/ui/src/component/advanced/RectMask2D.ts @@ -2,7 +2,6 @@ import { Component, DependentMode, Entity, - UIElementUtils, Vector2, Vector3, Vector4, @@ -113,12 +112,12 @@ export class RectMask2D extends Component { // @ts-ignore override _onEnableInScene(): void { - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); } // @ts-ignore override _onDisableInScene(): void { - this.entity._updateUIHierarchyVersion(UIElementUtils._hierarchyCounter); + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); } // @ts-ignore diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ea5b3a6b38..b15041ec66 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,6 +12,7 @@ import { ShaderPass } from "@galacean/engine"; import * as GUIComponent from "./component"; +import { UICanvas } from "./component"; import uiDefaultFs from "./shader/uiDefault.fs.glsl"; import uiDefaultVs from "./shader/uiDefault.vs.glsl"; export * from "./component"; @@ -48,7 +49,9 @@ export class EngineExtension { export class EntityExtension { _uiHierarchyVersion = 0; - _updateUIHierarchyVersion(version: number): void { + // Defaults to the current counter so canvas-hosted renderers (e.g. spine) can stamp + // without reaching into ui internals. + _updateUIHierarchyVersion(version: number = UICanvas._hierarchyCounter): void { if (this._uiHierarchyVersion !== version) { this._uiHierarchyVersion = version; // @ts-ignore @@ -68,7 +71,7 @@ declare module "@galacean/engine" { // @internal _uiHierarchyVersion: number; // @internal - _updateUIHierarchyVersion(version: number): void; + _updateUIHierarchyVersion(version?: number): void; } } From 83d7bf6bc3ae10d250269c7fa76c6e1e14bab54c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 16 Jul 2026 18:32:50 +0800 Subject: [PATCH 6/8] fix(ui): keep element listeners in sync when the listening chain shrinks _registerListener truncated the listening array without unregistering the dropped tail, leaving stale listeners on ex-ancestor entities. Releasing them exposed that nested-canvas demotion relied on such a leaked listener to hear the same CanvasEnableInScene dispatch again: the enabling canvas claims root status only after its dispatch returns, so the demote cascade searched for it in vain and promoted nested canvases to root instead. Hand the enabling canvas down the cascade as the successor. Co-Authored-By: Claude Fable 5 --- packages/ui/src/Utils.ts | 6 ++++++ packages/ui/src/component/UICanvas.ts | 12 ++++++++---- tests/src/ui/UICanvas.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index 31af29e95e..fc78f20adc 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -231,6 +231,12 @@ export class Utils { entity = entity.parent; count++; } + // A shorter chain drops tail entries — they must release the listener, or the bound + // element stays reachable from (and invocable by) entities it no longer tracks. + for (let i = count, n = listeningEntities.length; i < n; i++) { + // @ts-ignore + listeningEntities[i]._unRegisterModifyListener(listener); + } listeningEntities.length = count; } diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index c03707e561..254ae87644 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -400,7 +400,9 @@ export class UICanvas extends Component implements IUIElement { this._setIsRootCanvas(!rootCanvas); Utils.setRootCanvas(this, rootCanvas); } else if (flag === EntityUIModifyFlags.CanvasEnableInScene) { - this._setIsRootCanvas(false); + // The enabling canvas has not claimed root status at dispatch time, so searches + // inside the demote cascade cannot find it — hand it down as the successor. + this._setIsRootCanvas(false, param); Utils.setRootCanvas(this, param); } } else { @@ -636,7 +638,7 @@ export class UICanvas extends Component implements IUIElement { } } - private _setIsRootCanvas(isRootCanvas: boolean): void { + private _setIsRootCanvas(isRootCanvas: boolean, successor: UICanvas = null): void { if (this._isRootCanvas !== isRootCanvas) { this._isRootCanvas = isRootCanvas; this._updateCameraObserver(); @@ -647,8 +649,10 @@ export class UICanvas extends Component implements IUIElement { const { _disorderedElements: disorderedElements } = this; disorderedElements.forEach((element: IUIElement) => { if (element instanceof UICanvas) { - const rootCanvas = Utils.searchRootCanvasInParents(element); - element._setIsRootCanvas(!rootCanvas); + // `successor` covers the enable-driven demote, where the search cannot see the + // enabling canvas yet (it claims root status only after its dispatch returns). + const rootCanvas = Utils.searchRootCanvasInParents(element) ?? successor; + element._setIsRootCanvas(!rootCanvas, successor); Utils.setRootCanvas(element, rootCanvas); } else { // Reset the element's own canvas state (stale _rootCanvas/_indexInRootCanvas and diff --git a/tests/src/ui/UICanvas.test.ts b/tests/src/ui/UICanvas.test.ts index f02ee765af..314af5218b 100644 --- a/tests/src/ui/UICanvas.test.ts +++ b/tests/src/ui/UICanvas.test.ts @@ -386,6 +386,33 @@ describe("UICanvas", async () => { // @ts-ignore expect(image._getRootCanvas()).to.eq(outerCanvas); + outerEntity.destroy(); + }); + it("re-roots nested canvases to the enabling ancestor canvas", () => { + const outerEntity = root.createChild("outer"); + const midEntity = outerEntity.createChild("mid"); + const midCanvas = midEntity.addComponent(UICanvas); + const innerEntity = midEntity.createChild("inner"); + const innerCanvas = innerEntity.addComponent(UICanvas); + // @ts-ignore + expect(midCanvas._isRootCanvas).to.be.true; + // @ts-ignore + expect(innerCanvas._isRootCanvas).to.be.false; + + // The enabling canvas claims root status only after its enable dispatch returns, so the + // demote cascade must hand it down instead of searching for it. + const outerCanvas = outerEntity.addComponent(UICanvas); + // @ts-ignore + expect(outerCanvas._isRootCanvas).to.be.true; + // @ts-ignore + expect(midCanvas._isRootCanvas).to.be.false; + // @ts-ignore + expect(innerCanvas._isRootCanvas).to.be.false; + // @ts-ignore + expect(midCanvas._getRootCanvas()).to.eq(outerCanvas); + // @ts-ignore + expect(innerCanvas._getRootCanvas()).to.eq(outerCanvas); + outerEntity.destroy(); }); }); From 27058581019e13afb1cc74463c6abeba52f13b64 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 16 Jul 2026 18:33:09 +0800 Subject: [PATCH 7/8] fix(spine): re-evaluate hosting on every transition signal Hosting flips (renderer registration + macro) were reachable only from the Parent branch of the renderer's own listener, so every other transition left a split brain: a canvas enabled on an ancestor double-rendered (and crashed the overlay pass), a demoted root canvas left the renderer in no render list even after re-enabling, an enable-frame reparent orphaned it, and a world-to-world reparent left new ancestors unheard. _refreshHosting now settles registration, canvas bookkeeping and listener scope in one place; Parent events resolve eagerly, canvas enable/demote signals defer to a pre-update settle (their dispatch happens before the canvas claims root status). The listener truncation also releases dropped tail entities, and hosted raycasts respect RectMask2D clipping. Co-Authored-By: Claude Fable 5 --- .../src/renderer/SpineAnimationRenderer.ts | 200 ++++++++++-------- .../spine/SpineAnimationRendererUI.test.ts | 155 +++++++++++++- 2 files changed, 263 insertions(+), 92 deletions(-) diff --git a/packages/spine/src/renderer/SpineAnimationRenderer.ts b/packages/spine/src/renderer/SpineAnimationRenderer.ts index 055747c29f..1731db5933 100644 --- a/packages/spine/src/renderer/SpineAnimationRenderer.ts +++ b/packages/spine/src/renderer/SpineAnimationRenderer.ts @@ -74,7 +74,8 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg /** * Whether this renderer can be picked up by UI raycasts while hosted inside a `UICanvas`. - * The hit area is the skeleton's world bounds. + * The hit area is the skeleton's world bounds, excluding regions clipped away by + * ancestor `RectMask2D`s. */ @assignmentClone raycastEnabled = false; @@ -247,6 +248,9 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg * @internal */ override update(delta: number): void { + // Deferred hosting changes (a canvas enabled on an ancestor, a demoted root canvas) + // must resolve before this frame builds vertices and renders. + this._settleHosting(); const { _state: state, _skeleton: skeleton } = this; if (!state || !skeleton) return; const runtime = getSpineRuntime(); @@ -262,23 +266,11 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg */ // @ts-ignore override _onEnableInScene(): void { + // Start world-registered, then resolve: _refreshHosting hands the registration to the + // canvas when an enabled root canvas is found above. // @ts-ignore - const componentsManager = this.scene._componentsManager; - // @ts-ignore - this._overrideUpdate && componentsManager.addOnUpdateRenderers(this); - const rootCanvas = this._searchRootCanvasInParents(); - this._setHostedByUICanvas(!!rootCanvas, componentsManager, false); - if (rootCanvas) { - // The mixin method exists only when the ui package is loaded — without it there are - // no canvases to notify. It stamps the current hierarchy counter by default. - (this.entity as any)._updateUIHierarchyVersion?.(); - this._setRootCanvasDirty(); - this._setGroupDirty(); - } else { - componentsManager.addRenderer(this); - // Listen to the whole parent chain so moving under a canvas re-homes the renderer. - this._registerListeners(this.entity, null, this._rootCanvasListener, this._rootCanvasListeningEntities); - } + super._onEnableInScene(); + this._refreshHosting(); } /** @@ -286,14 +278,18 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg */ // @ts-ignore override _onDisableInScene(): void { - // @ts-ignore - const componentsManager = this.scene._componentsManager; - // @ts-ignore - this._overrideUpdate && componentsManager.removeOnUpdateRenderers(this); if (this._hostedByUICanvas) { + // @ts-ignore + this._overrideUpdate && this.scene._componentsManager.removeOnUpdateRenderers(this); + // The mixin method exists only when the ui package is loaded — without it there are + // no canvases to notify. It stamps the current hierarchy counter by default. (this.entity as any)._updateUIHierarchyVersion?.(); + this._hostedByUICanvas = false; + this.shaderData.disableMacro(SpineAnimationRenderer._uiRectClipMacro); + this._rectMasks.length = 0; } else { - componentsManager.removeRenderer(this); + // @ts-ignore + super._onDisableInScene(); } this._cleanRootCanvas(); this._cleanGroup(); @@ -310,48 +306,41 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg const engine = this.engine as any; const renderElementPool = engine._renderElementPool; const rootCanvas = this._hostedByUICanvas ? (this._getRootCanvas() as IUICanvas) : null; + let priority: number; + let distanceForSort: number; + let renderElements: any[] = null; + let renderPipeline: any = null; if (rootCanvas) { if (this.globalAlpha <= 0) { return; } - const priority = rootCanvas.sortOrder; - const distanceForSort = rootCanvas._sortDistance; - const renderElements = rootCanvas._renderElements; - for (let i = 0, n = _subPrimitives.length; i < n; i++) { - let material = materials[i]; - if (!material) { - continue; - } - if (material.destroyed || material.shader.destroyed) { - material = engine._basicResources.meshMagentaMaterial; - } - const renderElement = renderElementPool.get(); - renderElement.set(this, material, _primitive, _subPrimitives[i]); - // The overlay pass renders canvas elements without the pipeline's pushRenderElement - // (which assigns subShader elsewhere); camera-mode canvases overwrite this later. - renderElement.subShader = material.shader.subShaders[0]; - renderElement.priority = priority; - renderElement.distanceForSort = distanceForSort; - renderElements.push(renderElement); - } + priority = rootCanvas.sortOrder; + distanceForSort = rootCanvas._sortDistance; + renderElements = rootCanvas._renderElements; + } else if (this._hostedByUICanvas) { + // Hosting is mid-transition (the canvas demoted after this frame's settle) — skip. + return; } else { - const priority = this.priority; - const distanceForSort = (this as any)._distanceForSort; - const renderPipeline = context.camera._renderPipeline; - for (let i = 0, n = _subPrimitives.length; i < n; i++) { - let material = materials[i]; - if (!material) { - continue; - } - if (material.destroyed || material.shader.destroyed) { - material = engine._basicResources.meshMagentaMaterial; - } - const renderElement = renderElementPool.get(); - renderElement.set(this, material, _primitive, _subPrimitives[i]); - renderElement.priority = priority; - renderElement.distanceForSort = distanceForSort; - renderPipeline.pushRenderElement(context, renderElement); + priority = this.priority; + distanceForSort = (this as any)._distanceForSort; + renderPipeline = context.camera._renderPipeline; + } + for (let i = 0, n = _subPrimitives.length; i < n; i++) { + let material = materials[i]; + if (!material) { + continue; + } + if (material.destroyed || material.shader.destroyed) { + material = engine._basicResources.meshMagentaMaterial; } + const renderElement = renderElementPool.get(); + renderElement.set(this, material, _primitive, _subPrimitives[i]); + // The overlay pass renders canvas elements without the pipeline's pushRenderElement + // (which assigns subShader elsewhere); the camera passes overwrite this assignment. + renderElement.subShader = material.shader.subShaders[0]; + renderElement.priority = priority; + renderElement.distanceForSort = distanceForSort; + renderElements ? renderElements.push(renderElement) : renderPipeline.pushRenderElement(context, renderElement); } } @@ -368,15 +357,7 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg */ _getRootCanvas(): IUICanvas { if (this._isRootCanvasDirty) { - this._isRootCanvasDirty = false; - const rootCanvas = this._searchRootCanvasInParents(); - this._registerRootCanvas(rootCanvas); - this._registerListeners( - this.entity, - rootCanvas?.entity.parent ?? null, - this._rootCanvasListener, - this._rootCanvasListeningEntities - ); + this._refreshHosting(); } return this._rootCanvas; } @@ -419,11 +400,15 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg switch (flag) { case EntityModifyFlags.Parent: this._refreshHosting(); - this._setRootCanvasDirty(); - this._setGroupDirty(); case EntityModifyFlags.Child: (param as any)._updateUIHierarchyVersion?.(); break; + case EntityUIModifyFlags.CanvasEnableInScene: + // The enabling canvas has not claimed root status at dispatch time, so a search now + // would miss it — defer resolution to the pre-update settle. + this._setRootCanvasDirty(); + this._setGroupDirty(); + break; default: break; } @@ -452,12 +437,16 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg /** * @internal - * Hosted hit-testing against the skeleton's world bounds. + * Hosted hit-testing against the skeleton's world bounds; regions clipped away by + * ancestor `RectMask2D`s are not hittable. */ _raycast(ray: Ray, out: IUIHitResult, distance: number = Number.MAX_SAFE_INTEGER): boolean { const curDistance = ray.intersectBox(this.bounds); if (curDistance >= 0 && curDistance < distance) { const hitPoint = ray.getPoint(curDistance, SpineAnimationRenderer._tempHitPoint); + if (!this._isRaycastVisibleByRectMask(hitPoint)) { + return false; + } out.component = this; out.distance = curDistance; out.entity = this.entity; @@ -587,20 +576,11 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg return cached; } - private _setHostedByUICanvas(hosted: boolean, componentsManager: any, switchRegistration: boolean): void { - if (this._hostedByUICanvas === hosted && switchRegistration) return; - this._hostedByUICanvas = hosted; - if (switchRegistration) { - if (hosted) { - componentsManager.removeRenderer(this); - } else { - componentsManager.addRenderer(this); - } - } - if (hosted) { - this.shaderData.enableMacro(SpineAnimationRenderer._uiRectClipMacro); - } else { - this.shaderData.disableMacro(SpineAnimationRenderer._uiRectClipMacro); + private _settleHosting(): void { + // The divergence check catches half-resolved states: a canvas-side walk may assign + // _rootCanvas without switching the registration (it only knows the IUIElement contract). + if (this._isRootCanvasDirty || !!this._rootCanvas !== this._hostedByUICanvas) { + this._refreshHosting(); } } @@ -608,20 +588,52 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg const rootCanvas = this._searchRootCanvasInParents(); const hosted = !!rootCanvas; if (hosted !== this._hostedByUICanvas) { + this._hostedByUICanvas = hosted; // @ts-ignore const componentsManager = this.scene._componentsManager; - this._setHostedByUICanvas(hosted, componentsManager, true); + const shaderData = this.shaderData; if (hosted) { - (this.entity as any)._updateUIHierarchyVersion?.(); + componentsManager.removeRenderer(this); + shaderData.enableMacro(SpineAnimationRenderer._uiRectClipMacro); } else { - // Re-arm whole-chain listeners (the canvas-scoped range no longer covers the new parents). - this._registerRootCanvas(null); - this._isRootCanvasDirty = false; - this._registerListeners(this.entity, null, this._rootCanvasListener, this._rootCanvasListeningEntities); - this._cleanGroup(); + componentsManager.addRenderer(this); + shaderData.disableMacro(SpineAnimationRenderer._uiRectClipMacro); this._rectMasks.length = 0; } } + // Settle the canvas registration and listener scope even without a flip: a world→world + // reparent introduces ancestors that must be listened to, and the canvas walk and the + // camera pipeline both consume this state — nothing may stay half-resolved. + this._isRootCanvasDirty = false; + this._registerRootCanvas(rootCanvas); + this._registerListeners( + this.entity, + rootCanvas?.entity.parent ?? null, + this._rootCanvasListener, + this._rootCanvasListeningEntities + ); + if (hosted) { + this._setGroupDirty(); + // The mixin method exists whenever a canvas can exist (it ships with the ui package); + // stamping makes the hosting canvas re-walk and order this renderer in. + (this.entity as any)._updateUIHierarchyVersion?.(); + } else { + this._cleanGroup(); + } + } + + private _isRaycastVisibleByRectMask(hitPointWorld: Vector3): boolean { + const rectMasks = this._rectMasks; + for (let i = 0, n = rectMasks.length; i < n; i++) { + const rectMask = rectMasks[i]; + if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { + continue; + } + if (!rectMask._containsWorldPoint(hitPointWorld)) { + return false; + } + } + return true; } private _searchRootCanvasInParents(): IUICanvas { @@ -732,6 +744,12 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg entity = entity.parent; count++; } + // A shorter chain drops tail entries — they must release the listener, or the bound + // component stays reachable from (and invocable by) entities it no longer tracks. + for (let i = count, n = listeningEntities.length; i < n; i++) { + // @ts-ignore + listeningEntities[i]._unRegisterModifyListener(listener); + } listeningEntities.length = count; } diff --git a/tests/src/spine/SpineAnimationRendererUI.test.ts b/tests/src/spine/SpineAnimationRendererUI.test.ts index 931108557b..8668b0c9bd 100644 --- a/tests/src/spine/SpineAnimationRendererUI.test.ts +++ b/tests/src/spine/SpineAnimationRendererUI.test.ts @@ -1,7 +1,7 @@ import { Camera, Entity, Ray, Texture2D, Vector3, WebGLEngine } from "@galacean/engine"; import { getSpineRuntime, SpineAnimationRenderer, SpineResource } from "@galacean/engine-spine"; import "@galacean/engine-spine-core-4.2"; -import { CanvasRenderMode, RectMask2D, UICanvas, UIGroup } from "@galacean/engine-ui"; +import { CanvasRenderMode, RectMask2D, UICanvas, UIGroup, UITransform } from "@galacean/engine-ui"; import { beforeAll, describe, expect, it } from "vitest"; // Legacy-format atlas text (also parsed by the 4.2 parser): one 4x4 page with one region. @@ -295,6 +295,159 @@ describe("SpineAnimationRenderer inside UICanvas", () => { canvasEntity.destroy(); }); + it("re-homes when a canvas is enabled on an ancestor at runtime", () => { + const before = worldRendererCount(); + const holder = rootEntity.createChild("holder"); + const spineEntity = holder.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + expect(worldRendererCount()).to.equal(before + 1); + pump(engine); + + const canvas = holder.addComponent(UICanvas); + canvas.renderMode = CanvasRenderMode.WorldSpace; + pump(engine); + expect(worldRendererCount()).to.equal(before); + expect((canvas as any)._renderElements.length).to.equal(1); + expect((canvas as any)._renderElements[0].component).to.equal(renderer); + holder.destroy(); + }); + + it("does not throw when an overlay canvas is enabled above a world-space spine", () => { + const before = worldRendererCount(); + const holder = rootEntity.createChild("holder"); + const spineEntity = holder.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + pump(engine); + + const canvas = holder.addComponent(UICanvas); + canvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + expect(() => pump(engine)).to.not.throw(); + expect(worldRendererCount()).to.equal(before); + expect((canvas as any)._renderElements.length).to.equal(1); + holder.destroy(); + }); + + it("falls back to world rendering when its root canvas is disabled and returns on re-enable", () => { + const before = worldRendererCount(); + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + expect(worldRendererCount()).to.equal(before); + pump(engine); + + canvas.enabled = false; + pump(engine); + expect(worldRendererCount()).to.equal(before + 1); + + canvas.enabled = true; + expect(() => pump(engine)).to.not.throw(); + expect(worldRendererCount()).to.equal(before); + expect((canvas as any)._renderElements.length).to.equal(1); + canvasEntity.destroy(); + }); + + it("switches to world rendering when reparented out in the frame it was enabled", () => { + const before = worldRendererCount(); + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const spineEntity = canvasEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + expect(worldRendererCount()).to.equal(before); + + // Reparent before any engine.update runs. + rootEntity.addChild(spineEntity); + expect(worldRendererCount()).to.equal(before + 1); + pump(engine); + expect(worldRendererCount()).to.equal(before + 1); + + spineEntity.destroy(); + canvasEntity.destroy(); + }); + + it("re-homes when an ancestor subtree is moved under a canvas", () => { + const before = worldRendererCount(); + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.WorldSpace); + const holder = rootEntity.createChild("holder"); + const spineEntity = rootEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + expect(worldRendererCount()).to.equal(before + 1); + + holder.addChild(spineEntity); + expect(worldRendererCount()).to.equal(before + 1); + canvasEntity.addChild(holder); + expect(worldRendererCount()).to.equal(before); + pump(engine); + expect((canvas as any)._renderElements.length).to.equal(1); + + holder.destroy(); + canvasEntity.destroy(); + }); + + it("unregisters listeners from ancestors dropped by a shorter listening chain", () => { + const modifyListenerCount = (entity: Entity): number => + // @ts-ignore + entity._modifyFlagManager?._listeners.length ?? 0; + const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); + const a = rootEntity.createChild("a"); + const b = a.createChild("b"); + const spineEntity = b.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + expect(modifyListenerCount(a)).to.equal(1); + pump(engine); + + // The listening chain shrinks from [spine, b, a, root] to [spine, canvasEntity]. + canvasEntity.addChild(spineEntity); + pump(engine); + expect(modifyListenerCount(a)).to.equal(0); + expect(modifyListenerCount(b)).to.equal(0); + + // A leaked listener would fire on the destroyed component when its ex-ancestors move. + spineEntity.destroy(); + const elsewhere = rootEntity.createChild("elsewhere"); + expect(() => { + elsewhere.addChild(a); + pump(engine); + }).to.not.throw(); + elsewhere.destroy(); + canvasEntity.destroy(); + }); + + it("does not hit regions a RectMask2D clips away", () => { + const { canvasEntity, canvas } = createCanvas(CanvasRenderMode.WorldSpace); + const maskEntity = canvasEntity.createChild("mask"); + const mask = maskEntity.addComponent(RectMask2D); + (maskEntity.transform as UITransform).size.set(2, 8); + const spineEntity = maskEntity.createChild("spine"); + const renderer = spineEntity.addComponent(SpineAnimationRenderer); + renderer.resource = createSpineResource(engine); + renderer.raycastEnabled = true; + pump(engine); + + const out = { + entity: null, + distance: 0, + point: new Vector3(), + normal: new Vector3(), + component: null + }; + // The 4x4 skeleton spans x in [-2, 2]; the mask rect spans x in [-1, 1]. + const insideRay = new Ray(new Vector3(0, 0, 10), new Vector3(0, 0, -1)); + const clippedRay = new Ray(new Vector3(1.5, 0, 10), new Vector3(0, 0, -1)); + expect((canvas as any)._raycast(insideRay, out)).to.be.true; + expect(out.component).to.equal(renderer); + expect((canvas as any)._raycast(clippedRay, out)).to.be.false; + + // Disabled masks stop clipping the hit test. + mask.enabled = false; + expect((canvas as any)._raycast(clippedRay, out)).to.be.true; + canvasEntity.destroy(); + }); + it("destroy while hosted removes its cached materials and releases the primitive", () => { const { canvasEntity } = createCanvas(CanvasRenderMode.WorldSpace); const spineEntity = canvasEntity.createChild("spine"); From 3c6c88c04b310c361394917986686d1d97cc97d6 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 16 Jul 2026 18:33:27 +0800 Subject: [PATCH 8/8] refactor: drop dual-design leftovers and type element containers - delete the vite alias for the abandoned spine-ui package - reword the ISpineRenderTarget doc to the single-component design - remove the orphaned UIRenderer._tempRect scratch vector - type _disorderedElements as DisorderedArray instead of hand-written structural stubs, restoring type checking on the swap-delete index fixup Co-Authored-By: Claude Fable 5 --- e2e/.dev/vite.config.js | 1 - packages/core/src/ui/IUICanvas.ts | 3 ++- packages/core/src/ui/IUIElement.ts | 3 ++- packages/spine/src/runtime/ISpineRenderTarget.ts | 2 +- packages/ui/src/component/UIRenderer.ts | 2 -- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/e2e/.dev/vite.config.js b/e2e/.dev/vite.config.js index 779fb14410..aa2d64d151 100644 --- a/e2e/.dev/vite.config.js +++ b/e2e/.dev/vite.config.js @@ -57,7 +57,6 @@ module.exports = { // "Class constructor cannot be invoked without 'new'". "@galacean/engine-spine-core-4.2": path.resolve(__dirname, "../../packages/spine-core-4.2/src/index.ts"), "@galacean/engine-spine-core-3.8": path.resolve(__dirname, "../../packages/spine-core-3.8/src/index.ts"), - "@galacean/engine-spine-ui": path.resolve(__dirname, "../../packages/spine-ui/src/index.ts"), "@galacean/engine-spine": path.resolve(__dirname, "../../packages/spine/src/index.ts") } }, diff --git a/packages/core/src/ui/IUICanvas.ts b/packages/core/src/ui/IUICanvas.ts index 4ae7020860..fa7e17cb84 100644 --- a/packages/core/src/ui/IUICanvas.ts +++ b/packages/core/src/ui/IUICanvas.ts @@ -2,6 +2,7 @@ import { Camera } from "../Camera"; import { Entity } from "../Entity"; import { RenderContext } from "../RenderPipeline/RenderContext"; import { RenderElement } from "../RenderPipeline/RenderElement"; +import type { DisorderedArray } from "../utils/DisorderedArray"; import { IUIElement } from "./IUIElement"; /** @@ -16,7 +17,7 @@ export interface IUICanvas { _sortDistance: number; _realRenderMode: number; _renderElements: RenderElement[]; - _disorderedElements: { length: number; add(element: IUIElement): void; deleteByIndex(index: number): IUIElement }; + _disorderedElements: DisorderedArray; _canRender(camera: Camera): boolean; _prepareRender(renderContext: RenderContext): void; } diff --git a/packages/core/src/ui/IUIElement.ts b/packages/core/src/ui/IUIElement.ts index 45b65288d4..d9e6bc3cb6 100644 --- a/packages/core/src/ui/IUIElement.ts +++ b/packages/core/src/ui/IUIElement.ts @@ -1,5 +1,6 @@ import { Ray, Vector3, Vector4 } from "@galacean/engine-math"; import { Entity } from "../Entity"; +import type { DisorderedArray } from "../utils/DisorderedArray"; import { IUICanvas } from "./IUICanvas"; /** @@ -52,7 +53,7 @@ export interface IUIGroup { entity: Entity; /** Marker used to identify a group component without a ui-package dependency. */ _isUIGroup: boolean; - _disorderedElements: { length: number; add(element: any): void; deleteByIndex(index: number): any }; + _disorderedElements: DisorderedArray; _getGlobalAlpha(): number; } diff --git a/packages/spine/src/runtime/ISpineRenderTarget.ts b/packages/spine/src/runtime/ISpineRenderTarget.ts index ccde6d3c06..fa81d4f443 100644 --- a/packages/spine/src/runtime/ISpineRenderTarget.ts +++ b/packages/spine/src/runtime/ISpineRenderTarget.ts @@ -5,7 +5,7 @@ import type { SpineBlendMode } from "../enums/SpineBlendMode"; * The write target a spine core package's generator fills while building the renderable primitive. * * @remarks - * Implemented by the render hosts (world-space `SpineAnimationRenderer` and the UI-space renderer). + * Implemented by `SpineAnimationRenderer` for both world-space and UI-hosted rendering. * This is the narrow, spine-core-free contract that lets the version-specific generator live in a * core package while the buffer/material/bounds bookkeeping stays in the shared renderer — the * analogue of how engine physics colliders expose a native handle. diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index d019438ead..56b4f3ca76 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -43,8 +43,6 @@ export class UIRenderer extends Renderer implements IUIRenderer { static _tempPlane: Plane = new Plane(); /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_UITexture"); - /** @internal */ - static _tempRect: Vector4 = new Vector4(); /** @internal Marker checked by the `UICanvas` walk (`IUIRenderer`). */ @ignoreClone