diff --git a/e2e/.dev/vite.config.js b/e2e/.dev/vite.config.js index 498a9a0244..aa2d64d151 100644 --- a/e2e/.dev/vite.config.js +++ b/e2e/.dev/vite.config.js @@ -50,7 +50,15 @@ 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": 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/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..fa7e17cb84 100644 --- a/packages/core/src/ui/IUICanvas.ts +++ b/packages/core/src/ui/IUICanvas.ts @@ -2,15 +2,22 @@ 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"; /** - * @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: 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 new file mode 100644 index 0000000000..d9e6bc3cb6 --- /dev/null +++ b/packages/core/src/ui/IUIElement.ts @@ -0,0 +1,112 @@ +import { Ray, Vector3, Vector4 } from "@galacean/engine-math"; +import { Entity } from "../Entity"; +import type { DisorderedArray } from "../utils/DisorderedArray"; +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 ui package's `Utils`. + */ +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: DisorderedArray; + _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 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 + * Non-ui implementers switch their own `ComponentsManager` renderer registration off while + * hosted and push their render elements into `IUICanvas._renderElements` from `_render`. + */ +export interface IUIRenderer extends IUIGroupAble { + /** Marker checked by the canvas walk. */ + _isUIRenderer: 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/index.ts b/packages/core/src/ui/index.ts new file mode 100644 index 0000000000..d1fb8e3297 --- /dev/null +++ b/packages/core/src/ui/index.ts @@ -0,0 +1,3 @@ +export { EntityUIModifyFlags, GroupModifyFlags, RootCanvasModifyFlags } from "./IUIElement"; +export type { IUIElement, IUIGroup, IUIGroupAble, IUIHitResult, IUIRenderer } from "./IUIElement"; +export type { IUICanvas } from "./IUICanvas"; 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..1731db5933 100644 --- a/packages/spine/src/renderer/SpineAnimationRenderer.ts +++ b/packages/spine/src/renderer/SpineAnimationRenderer.ts @@ -7,19 +7,26 @@ import { BufferUsage, deepClone, Entity, + EntityModifyFlags, + EntityUIModifyFlags, ignoreClone, IndexBufferBinding, IndexFormat, Material, Primitive, + Ray, Renderer, + ShaderMacro, + ShaderProperty, SubPrimitive, Texture2D, Vector3, + Vector4, VertexBufferBinding, VertexElement, VertexElementFormat } 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"; @@ -29,12 +36,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 `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 { +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); 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 +72,14 @@ 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, excluding regions clipped away by + * ancestor `RectMask2D`s. + */ + @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 (`IUIRenderer`). */ + @ignoreClone + _isUIRenderer = 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); } /** @@ -162,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(); @@ -170,6 +259,42 @@ 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 { + // Start world-registered, then resolve: _refreshHosting hands the registration to the + // canvas when an enabled root canvas is found above. + // @ts-ignore + super._onEnableInScene(); + this._refreshHosting(); + } + + /** + * @internal + */ + // @ts-ignore + override _onDisableInScene(): void { + 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 { + // @ts-ignore + super._onDisableInScene(); + } + this._cleanRootCanvas(); + this._cleanGroup(); + } + /** * @internal */ @@ -179,10 +304,27 @@ 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; + 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; + } + 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 { + 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) { @@ -193,9 +335,12 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg } 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; - renderPipeline.pushRenderElement(context, renderElement); + renderElements ? renderElements.push(renderElement) : renderPipeline.pushRenderElement(context, renderElement); } } @@ -207,6 +352,111 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg BoundingBox.transform(this._localBounds, this.entity.transform.worldMatrix, worldBounds); } + /** + * @internal + */ + _getRootCanvas(): IUICanvas { + if (this._isRootCanvasDirty) { + this._refreshHosting(); + } + return this._rootCanvas; + } + + /** + * @internal + */ + _getGroup(): IUIGroup { + if (this._isGroupDirty) { + this._isGroupDirty = false; + const rootCanvas = this._getRootCanvas(); + 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; + } + + /** + * @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(); + 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; + } + } + + /** + * @internal + */ + @ignoreClone + _groupListener(flag: number): void { + if (flag === EntityModifyFlags.Parent || flag === EntityUIModifyFlags.GroupEnableInScene) { + this._setGroupDirty(); + } + } + + /** + * @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; 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; + out.normal.copyFrom(this.entity.transform.worldForward); + out.point.copyFrom(hitPoint); + return true; + } + return false; + } + /** * @internal */ @@ -326,6 +576,191 @@ export class SpineAnimationRenderer extends Renderer implements ISpineRenderTarg return cached; } + 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(); + } + } + + private _refreshHosting(): void { + const rootCanvas = this._searchRootCanvasInParents(); + const hosted = !!rootCanvas; + if (hosted !== this._hostedByUICanvas) { + this._hostedByUICanvas = hosted; + // @ts-ignore + const componentsManager = this.scene._componentsManager; + const shaderData = this.shaderData; + if (hosted) { + componentsManager.removeRenderer(this); + shaderData.enableMacro(SpineAnimationRenderer._uiRectClipMacro); + } else { + 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 { + 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++; + } + // 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; + } + + 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/spine/src/runtime/ISpineRenderTarget.ts b/packages/spine/src/runtime/ISpineRenderTarget.ts index 50133ae9f8..fa81d4f443 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 `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. */ 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..fc78f20adc 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -1,16 +1,51 @@ -import { Entity, Matrix, Plane, Ray, Vector2, Vector3 } from "@galacean/engine"; +import { + Entity, + GroupModifyFlags, + Matrix, + Plane, + Ray, + RootCanvasModifyFlags, + ShaderData, + ShaderProperty, + 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,14 +97,14 @@ export class Utils { return false; } - static setRootCanvasDirty(element: IElement): void { + static setRootCanvasDirty(element: IUIElement): void { if (element._isRootCanvasDirty) return; element._isRootCanvasDirty = true; this._registerRootCanvas(element, null); element._onRootCanvasModify?.(RootCanvasModifyFlags.All); } - static setRootCanvas(element: IElement, rootCanvas: UICanvas): void { + static setRootCanvas(element: IUIElement, rootCanvas: UICanvas): void { element._isRootCanvasDirty = false; this._registerRootCanvas(element, rootCanvas); const fromEntity = element instanceof UICanvas ? element.entity.parent : element.entity; @@ -77,12 +112,12 @@ export class Utils { this._registerListener(fromEntity, toEntity, element._rootCanvasListener, element._rootCanvasListeningEntities); } - static cleanRootCanvas(element: IElement): void { + static cleanRootCanvas(element: IUIElement): void { this._registerRootCanvas(element, null); this._unRegisterListener(element._rootCanvasListener, element._rootCanvasListeningEntities); } - static searchRootCanvasInParents(element: IElement): UICanvas { + static searchRootCanvasInParents(element: IUIElement): UICanvas { let entity = element instanceof UICanvas ? element.entity.parent : element.entity; while (entity) { // @ts-ignore @@ -98,14 +133,14 @@ export class Utils { return null; } - static setGroupDirty(element: IGroupAble): void { + static setGroupDirty(element: IUIGroupAble): void { if (element._isGroupDirty) return; element._isGroupDirty = true; this._registerGroup(element, null); element._onGroupModify(GroupModifyFlags.All); } - static setGroup(element: IGroupAble, group: UIGroup): void { + static setGroup(element: IUIGroupAble, group: UIGroup): void { element._isGroupDirty = false; this._registerGroup(element, group); const rootCanvas = element._getRootCanvas(); @@ -118,12 +153,12 @@ export class Utils { } } - static cleanGroup(element: IGroupAble): void { + static cleanGroup(element: IUIGroupAble): void { this._registerGroup(element, null); this._unRegisterListener(element._groupListener, element._groupListeningEntities); } - 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; @@ -142,7 +177,7 @@ export class Utils { return null; } - private static _registerRootCanvas(element: IElement, canvas: UICanvas): void { + private static _registerRootCanvas(element: IUIElement, canvas: UICanvas): void { const preCanvas = element._rootCanvas; if (preCanvas !== canvas) { if (preCanvas) { @@ -159,7 +194,7 @@ export class Utils { } } - private static _registerGroup(element: IGroupAble, group: UIGroup): void { + private static _registerGroup(element: IUIGroupAble, group: UIGroup): void { const preGroup = element._group; if (preGroup !== group) { if (preGroup) { @@ -196,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; } @@ -206,4 +247,125 @@ export class Utils { } listeningEntities.length = 0; } + + /** + * @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; + } + + 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 (tempRect.z < maxX) { + maxX = tempRect.z; + clipSoftnessRight = softness.x; + } + if (tempRect.w < maxY) { + maxY = tempRect.w; + clipSoftnessTop = softness.y; + } + } + + 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); + } + } + + /** + * @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); + } + } } diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index 77dad56014..254ae87644 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -7,10 +7,13 @@ import { DisorderedArray, Entity, EntityModifyFlags, + EntityUIModifyFlags, Logger, MathUtil, Matrix, Ray, + Renderer, + RootCanvasModifyFlags, Vector2, Vector3, assignmentClone, @@ -19,12 +22,11 @@ import { ignoreClone, CloneUtils } 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"; @@ -36,10 +38,10 @@ 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 { /** @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,13 +72,13 @@ export class UICanvas extends Component implements IElement { _sortDistance: number = 0; /** @internal */ @ignoreClone - _orderedRenderers: UIRenderer[] = []; + _orderedRenderers: CanvasRenderable[] = []; /** @internal */ @ignoreClone _realRenderMode: number = CanvasRealRenderMode.None; /** @internal */ @ignoreClone - _disorderedElements: DisorderedArray = new DisorderedArray(); + _disorderedElements: DisorderedArray = new DisorderedArray(); @ignoreClone private _renderMode = CanvasRenderMode.WorldSpace; @@ -340,6 +342,8 @@ export class UICanvas extends Component implements IElement { break; } } + Utils.updateRectMaskClipState(renderer); + // @ts-ignore renderer._prepareRender(context); renderer._renderFrameCount = frameCount; } @@ -396,7 +400,9 @@ export class UICanvas extends Component implements IElement { 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 { @@ -415,7 +421,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) { @@ -504,7 +510,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 +524,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 as unknown as IUIRenderer)._isUIRenderer) { + 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) { @@ -631,7 +638,7 @@ export class UICanvas extends Component implements IElement { } } - private _setIsRootCanvas(isRootCanvas: boolean): void { + private _setIsRootCanvas(isRootCanvas: boolean, successor: UICanvas = null): void { if (this._isRootCanvas !== isRootCanvas) { this._isRootCanvas = isRootCanvas; this._updateCameraObserver(); @@ -640,14 +647,19 @@ export class UICanvas extends Component implements IElement { this.entity._updateUIHierarchyVersion(UICanvas._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); + // `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 { - Utils.setRootCanvasDirty(this); - Utils.setGroupDirty(element); + // 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); } }); disorderedElements.length = 0; @@ -731,16 +743,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 `IUIRenderer` contract. */ -export enum EntityUIModifyFlags { - CanvasEnableInScene = 0x4, - GroupEnableInScene = 0x8 -} - -export enum RootCanvasModifyFlags { - None = 0x0, - ReferenceResolutionPerUnit = 0x1, - All = 0x1 -} +export type CanvasRenderable = Renderer & IUIRenderer; diff --git a/packages/ui/src/component/UIGroup.ts b/packages/ui/src/component/UIGroup.ts index b876e05ec4..a61abb90a7 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 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 */ @ignoreClone _indexInGroup: number = -1; @@ -16,7 +26,7 @@ export class UIGroup extends Component implements IGroupAble { _rootCanvas: UICanvas; /** @internal */ @ignoreClone - _disorderedElements: DisorderedArray = new DisorderedArray(); + _disorderedElements: DisorderedArray = new DisorderedArray(); /** @internal */ @ignoreClone @@ -144,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; @@ -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..56b4f3ca76 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -23,14 +23,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 */ @@ -43,17 +43,10 @@ export class UIRenderer extends Renderer implements IGraphics { static _tempPlane: Plane = new Plane(); /** @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(); + /** @internal Marker checked by the `UICanvas` walk (`IUIRenderer`). */ + @ignoreClone + _isUIRenderer = true; /** * Custom boundary for raycast detection. * @remarks this is based on `this.entity.transform`. @@ -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,6 @@ export class UIRenderer extends Renderer implements IGraphics { this._update(context); } - this._updateRectMaskClipState(); this._render(context); // union camera global macro and renderer macro. @@ -375,120 +367,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/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/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/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; } } 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; -} 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..8668b0c9bd --- /dev/null +++ b/tests/src/spine/SpineAnimationRendererUI.test.ts @@ -0,0 +1,466 @@ +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, 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. +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("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"); + 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(); + }); +}); diff --git a/tests/src/ui/UICanvas.test.ts b/tests/src/ui/UICanvas.test.ts index 0519a1e98d..314af5218b 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,67 @@ 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(); + }); + 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(); + }); });