From 36dc785a749a4826895edd13fdd89ec136a578f6 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 8 Aug 2025 15:24:05 +0800 Subject: [PATCH 01/10] feat: update code --- packages/core/src/RenderPipeline/index.ts | 5 +- packages/core/src/Renderer.ts | 1 - packages/ui/src/Utils.ts | 58 +++- packages/ui/src/component/UIRenderer.ts | 27 ++ packages/ui/src/component/advanced/Mask.ts | 297 ++++++++++++++++++ .../ui/src/component/advanced/ScrollView.ts | 261 +++++++++++++++ packages/ui/src/component/advanced/Text.ts | 11 - packages/ui/src/component/index.ts | 10 +- 8 files changed, 652 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/component/advanced/Mask.ts create mode 100644 packages/ui/src/component/advanced/ScrollView.ts diff --git a/packages/core/src/RenderPipeline/index.ts b/packages/core/src/RenderPipeline/index.ts index 7161b57757..af4130800f 100644 --- a/packages/core/src/RenderPipeline/index.ts +++ b/packages/core/src/RenderPipeline/index.ts @@ -1,5 +1,8 @@ export { BasicRenderPipeline, RenderQueueFlags } from "./BasicRenderPipeline"; export { BatchUtils } from "./BatchUtils"; export { Blitter } from "./Blitter"; -export { RenderQueue } from "./RenderQueue"; export { PipelineStage } from "./enums/PipelineStage"; +export { RenderElement } from "./RenderElement"; +export { RenderQueue } from "./RenderQueue"; +export { SubRenderElement } from "./SubRenderElement"; + diff --git a/packages/core/src/Renderer.ts b/packages/core/src/Renderer.ts index 339c7ed9bd..9aea7d9314 100644 --- a/packages/core/src/Renderer.ts +++ b/packages/core/src/Renderer.ts @@ -47,7 +47,6 @@ export class Renderer extends Component implements IComponentCustomClone { _globalShaderMacro: ShaderMacroCollection = new ShaderMacroCollection(); @ignoreClone _renderFrameCount: number; - /** @internal */ @assignmentClone _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; /** @internal */ diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index f47e0a8053..69ca34547b 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -1,6 +1,8 @@ -import { Entity } from "@galacean/engine"; +import { Entity, Matrix, Plane, Ray, Vector2, Vector3 } from "@galacean/engine"; +import { UITransform } from "./component"; import { RootCanvasModifyFlags, UICanvas } from "./component/UICanvas"; import { GroupModifyFlags, UIGroup } from "./component/UIGroup"; +import { CanvasRenderMode } from "./enums/CanvasRenderMode"; import { IElement } from "./interface/IElement"; import { IGroupAble } from "./interface/IGroupAble"; @@ -149,4 +151,58 @@ export class Utils { } listeningEntities.length = 0; } + + static _tempRay: Ray = new Ray(); + static _tempPlane: Plane = new Plane(); + static _tempVec3: Vector3 = new Vector3(); + static _tempMat: Matrix = new Matrix(); + /** + * 屏幕上的点在组件中的局部位置 + */ + static screenToLocalPoint(position: Vector2, transform: UITransform, out: Vector3): Boolean { + const engine = transform.engine; + // 获取主画布 + let entity = transform.entity; + let rootCanvas: UICanvas; + 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) { + rootCanvas = component; + } + } + entity = entity.parent; + } + if (!rootCanvas) return false; + // 计算 Ray + const ray = this._tempRay; + switch (rootCanvas._realRenderMode) { + case CanvasRenderMode.ScreenSpaceOverlay: + // Screen to world ( Assume that world units have a one-to-one relationship with pixel units ) + ray.origin.set(position.x, engine.canvas.height - position.y, 1); + ray.direction.set(0, 0, -1); + break; + case CanvasRenderMode.ScreenSpaceCamera: + rootCanvas.renderCamera.screenPointToRay(position, ray); + break; + default: + // 暂不支持世界空间,详见 issue #2793 + return false; + } + // 用这个射线和 UI 平面相交获取局部坐标 + const plane = this._tempPlane; + const normal = plane.normal.copyFrom(transform.worldForward); + plane.distance = -Vector3.dot(normal, transform.worldPosition); + const curDistance = ray.intersectPlane(plane); + if (curDistance >= 0 && curDistance < Number.MAX_SAFE_INTEGER) { + const hitPointWorld = ray.getPoint(curDistance, this._tempVec3); + const worldMatrixInv = this._tempMat; + Matrix.invert(transform.worldMatrix, worldMatrixInv); + Vector3.transformCoordinate(hitPointWorld, worldMatrixInv, out); + return true; + } + return false; + } } diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index 285df1d122..afaed177a2 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -11,6 +11,8 @@ import { RendererUpdateFlags, ShaderMacroCollection, ShaderProperty, + SpriteMaskInteraction, + SpriteMaskLayer, Vector3, Vector4, assignmentClone, @@ -90,6 +92,31 @@ export class UIRenderer extends Renderer implements IGraphics { } } + + /** + * The mask layer the sprite renderer belongs to. + */ + get maskLayer(): SpriteMaskLayer { + return this._maskLayer; + } + + set maskLayer(value: SpriteMaskLayer) { + this._maskLayer = value; + } + + /** + * Interacts with the masks. + */ + get maskInteraction(): SpriteMaskInteraction { + return this._maskInteraction; + } + + set maskInteraction(value: SpriteMaskInteraction) { + if (this._maskInteraction !== value) { + this._maskInteraction = value; + } + } + /** * Whether this renderer be picked up by raycast. */ diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts new file mode 100644 index 0000000000..39921308ab --- /dev/null +++ b/packages/ui/src/component/advanced/Mask.ts @@ -0,0 +1,297 @@ +import { assignmentClone, BatchUtils, BoundingBox, Entity, ignoreClone, ISpriteRenderer, RenderElement, RendererUpdateFlags, RenderQueueFlags, ShaderProperty, SimpleSpriteAssembler, Sprite, SpriteMaskLayer, SpriteModifyFlags, SubRenderElement } from "@galacean/engine"; +import { UIRenderer, UITransform } from ".."; + +export class Mask extends UIRenderer implements ISpriteRenderer { + /** @internal */ + static _maskTextureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); + /** @internal */ + static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + + /** The mask layers the sprite mask influence to. */ + @assignmentClone + influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; + /** @internal */ + @ignoreClone + _renderElement; + /** @internal */ + @ignoreClone + _maskIndex: number = -1; + + @ignoreClone + private _sprite: Sprite = null; + @assignmentClone + private _flipX: boolean = false; + @assignmentClone + private _flipY: boolean = false; + + @assignmentClone + private _alphaCutoff: number = 0.5; + + + /** + * @internal + */ + override _getChunkManager() { + // @ts-ignore + return this.engine._batcherManager.primitiveChunkManagerMask; + } + + /** + * Flips the sprite on the X axis. + */ + get flipX(): boolean { + return this._flipX; + } + + set flipX(value: boolean) { + if (this._flipX !== value) { + this._flipX = value; + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** + * Flips the sprite on the Y axis. + */ + get flipY(): boolean { + return this._flipY; + } + + set flipY(value: boolean) { + if (this._flipY !== value) { + this._flipY = value; + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** + * The Sprite to render. + */ + get sprite(): Sprite { + return this._sprite; + } + + set sprite(value: Sprite | null) { + const lastSprite = this._sprite; + if (lastSprite !== value) { + if (lastSprite) { + this._addResourceReferCount(lastSprite, -1); + // @ts-ignore + lastSprite._updateFlagManager.removeListener(this._onSpriteChange); + } + this._dirtyUpdateFlag |= MaskUpdateFlags.All; + if (value) { + this._addResourceReferCount(value, 1); + // @ts-ignore + value._updateFlagManager.addListener(this._onSpriteChange); + this.shaderData.setTexture(Mask._maskTextureProperty, value.texture); + } else { + this.shaderData.setTexture(Mask._maskTextureProperty, null); + } + this._sprite = value; + } + } + + /** + * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. + */ + get alphaCutoff(): number { + return this._alphaCutoff; + } + + set alphaCutoff(value: number) { + if (this._alphaCutoff !== value) { + this._alphaCutoff = value; + this.shaderData.setFloat(Mask._alphaCutoffProperty, value); + } + } + + + /** + * @internal + */ + override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { + return BatchUtils.canBatchSpriteMask(elementA, elementB); + } + + /** + * @internal + */ + override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { + BatchUtils.batchFor2D(elementA, elementB); + } + + /** + * @internal + */ + constructor(entity: Entity) { + super(entity); + SimpleSpriteAssembler.resetData(this); + // @ts-ignore + this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); + this.shaderData.setFloat(Mask._alphaCutoffProperty, this._alphaCutoff); + this._renderElement = new RenderElement(); + this._renderElement.addSubRenderElement(new SubRenderElement()); + this._onSpriteChange = this._onSpriteChange.bind(this); + this.raycastEnabled = false; + } + + /** + * @internal + */ + _cloneTo(target: Mask, srcRoot: Entity, targetRoot: Entity): void { + // @ts-ignore + super._cloneTo(target, srcRoot, targetRoot); + target.sprite = this._sprite; + } + + /** + * @internal + */ + override _onEnableInScene(): void { + super._onEnableInScene(); + // @ts-ignore + this.scene._maskManager.addSpriteMask(this); + } + + /** + * @internal + */ + override _onDisableInScene(): void { + super._onDisableInScene(); + // @ts-ignore + this.scene._maskManager.removeSpriteMask(this); + } + + protected override _updateBounds(worldBounds: BoundingBox): void { + const sprite = this._sprite; + const rootCanvas = this._getRootCanvas(); + if (sprite && rootCanvas) { + const transform = this._transformEntity.transform; + const { size } = transform; + SimpleSpriteAssembler.updatePositions( + this, + transform.worldMatrix, + size.x, + size.y, + transform.pivot, + false, + false + ); + } else { + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } + } + + /** + * @inheritdoc + */ + protected override _render(context): void { + const { _sprite: sprite } = this; + const transform = this._transformEntity.transform; + const { x: width, y: height } = transform.size; + if (!sprite?.texture || !width || !height) { + return; + } + + let material = this.getMaterial(); + if (!material) { + return; + } + const { _engine: engine } = this; + // @todo: This question needs to be raised rather than hidden. + if (material.destroyed) { + // @ts-ignore + material = engine._basicResources.spriteMaskDefaultMaterial; + } + + // Update position + if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { + SimpleSpriteAssembler.updatePositions( + this, + transform.worldMatrix, + width, + height, + transform.pivot, + false, + false, + ); + this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; + } + + // Update uv + if (this._dirtyUpdateFlag & MaskUpdateFlags.UV) { + SimpleSpriteAssembler.updateUVs(this); + this._dirtyUpdateFlag &= ~MaskUpdateFlags.UV; + } + + const renderElement = this._renderElement; + const subRenderElement = renderElement.subRenderElements[0]; + renderElement.set(this.priority, 0); + const subChunk = this._subChunk; + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; + renderElement.addSubRenderElement(subRenderElement); + } + + /** + * @inheritdoc + */ + protected override _onDestroy(): void { + const sprite = this._sprite; + if (sprite) { + this._addResourceReferCount(sprite, -1); + // @ts-ignore + sprite._updateFlagManager.removeListener(this._onSpriteChange); + this._sprite = null; + } + + super._onDestroy(); + + if (this._subChunk) { + this._getChunkManager().freeSubChunk(this._subChunk); + this._subChunk = null; + } + + this._renderElement = null; + } + + @ignoreClone + private _onSpriteChange(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.texture: + this.shaderData.setTexture(Mask._maskTextureProperty, this.sprite.texture); + break; + case SpriteModifyFlags.region: + case SpriteModifyFlags.atlasRegionOffset: + this._dirtyUpdateFlag |= MaskUpdateFlags.WorldVolumeAndUV; + break; + case SpriteModifyFlags.atlasRegion: + this._dirtyUpdateFlag |= MaskUpdateFlags.UV; + break; + case SpriteModifyFlags.destroy: + this.sprite = null; + break; + default: + break; + } + } +} + + +/** + * @remarks Extends `RendererUpdateFlags`. + */ +enum MaskUpdateFlags { + /** UV. */ + UV = 0x2, + /** Automatic Size. */ + AutomaticSize = 0x4, + /** WorldVolume and UV. */ + WorldVolumeAndUV = 0x3, + /** All. */ + All = 0x7 +} diff --git a/packages/ui/src/component/advanced/ScrollView.ts b/packages/ui/src/component/advanced/ScrollView.ts new file mode 100644 index 0000000000..15fcf5823f --- /dev/null +++ b/packages/ui/src/component/advanced/ScrollView.ts @@ -0,0 +1,261 @@ +import { Entity, ignoreClone, PointerEventData, Rect, SafeLoopArray, Script, Vector2, Vector3 } from "@galacean/engine"; +import { Utils } from "../../Utils"; +import { UITransform } from "../UITransform"; + +/** + * Vertical scroll view + */ +export class ScrollView extends Script { + // 视口 + private _viewportTransform: UITransform; + // 内容 + private _contentTransform: UITransform; + // 类型 + private _mode: ScrollViewMode = ScrollViewMode.VerticalAndHorizontal; + // 滑动类型 + private _movementType: MovementType = MovementType.Clamped; + // 惯性 + private _inertia: boolean = true; + // 当前滑动速度 + private _velocity: Vector2 = new Vector2(); + // 惯性速度阈值 + private _threshold = 10; + // 惯性衰减系数 + private _decelerationRate: number = 0.05; + // 计算惯性时把每帧分成多个 1 / 240 秒时间片,模拟微积分 + private _inertiaBaseTimeInterval = 1 / 240; + + // 正在拖拽 + private _isDragging: boolean = false; + // 计算边界 + private _viewportRect: Rect = new Rect(); + private _contentRect: Rect = new Rect(); + @ignoreClone + private _listeners: SafeLoopArray = new SafeLoopArray(); + + // 拖动行为的起始点(以 viewport 的局部坐标系为参考坐标系) + private _startPoint: Vector3 = new Vector3(); + // **本帧**移动的起点(以 viewport 的局部坐标系为参考坐标系) + private _fromPoint: Vector3 = new Vector3(); + // **本帧**移动的终点(以 viewport 的局部坐标系为参考坐标系) + private _toPoint: Vector3 = new Vector3(); + + get mode(): ScrollViewMode { + return this._mode; + } + + set mode(value: number) { + this._mode = value; + } + + get viewport() { + return this._viewportTransform?.entity; + } + + set viewport(entity: Entity) { + this._viewportTransform = entity.transform as UITransform; + } + + get content(): Entity { + return this._contentTransform?.entity;; + } + + set content(entity: Entity) { + this._contentTransform = entity.transform as UITransform; + } + + /** + * 添加滑动回调 + * @param listener - The listening function + */ + addOnScroll(listener: (percentH: number, percentV: number) => void): void { + this._listeners.push({ fn: listener }); + } + + /** + * 移除滑动回调 + * @param listener - The listening function + */ + removeOnScroll(listener: (val: number) => void): void { + this._listeners.findAndRemove((value) => (value.fn === listener ? (value.destroyed = true) : false)); + } + + override onPointerBeginDrag(eventData: PointerEventData): void { + this._isDragging = true; + const { _viewportTransform: viewportTransform } = this; + if (!viewportTransform || !this._contentTransform) return; + // 初始化拖动的起始点 + Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, this._startPoint); + this._fromPoint.copyFrom(this._startPoint); + } + + override onPointerDrag(eventData: PointerEventData): void { + const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; + if (!viewportTransform || !contentTransform) return; + const { _fromPoint: fromPoint, _toPoint: toPoint } = this; + // 更新 toPoint + Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, toPoint); + // 从 fromPoint 到 toPoint + this._updateRect(); + this._computeScrolling(fromPoint, toPoint); + // 更新 fromPoint + fromPoint.copyFrom(toPoint) + } + + private _computeScrolling(from: Vector3, to: Vector3) { + const { _contentTransform: contentTransform, _mode: mode } = this; + const contentScale = contentTransform.scale; + // 根据 mode 计算 delta + let deltaX = (mode & ScrollViewMode.Horizontal) ? (to.x - from.x) * contentScale.x : 0; + let deltaY = (mode & ScrollViewMode.Vertical) ? (to.y - from.y) * contentScale.y : 0; + // 根据滑动类型计算滑块的运动 + switch (this._movementType) { + case MovementType.Clamped: + const { _viewportRect: viewportRect, _contentRect: contentRect } = this; + if (mode & ScrollViewMode.Horizontal) { + if (deltaX > 0) { + if (contentRect.x + deltaX > viewportRect.x) { + deltaX = viewportRect.x - contentRect.x; + } + } else if (deltaX < 0) { + if (contentRect.x + contentRect.width + deltaX < viewportRect.x + viewportRect.width) { + deltaX = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; + } + } + } + if (mode & ScrollViewMode.Vertical) { + if (deltaY > 0) { + if (contentRect.y + deltaY > viewportRect.y) { + deltaY = viewportRect.y - contentRect.y; + } + } else if (deltaY < 0) { + if (contentRect.y + contentRect.height + deltaY < viewportRect.y + viewportRect.height) { + deltaY = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; + } + } + } + break; + case MovementType.Elastic: + // 暂未实现 + break; + default: + break; + } + this._applyScrolling(deltaX, deltaY); + // 记录此时的速度,可以计算惯性 + const delta = this.engine.time.actualDeltaTime; + const invDeltaTime = 1 / delta; + const velocity = this._velocity; + velocity.set(deltaX * invDeltaTime, deltaY * invDeltaTime); + velocity.scale(Math.pow(this._decelerationRate, delta)) + } + + private _applyScrolling(dx: number, dy: number): void { + const contentPosition = this._contentTransform.position; + const { _contentRect: contentRect, _viewportRect: viewportRect } = this; + // 计算是否碰到边缘 + contentPosition.set(contentPosition.x + dx, contentPosition.y + dy, contentPosition.z); + const percentH = (viewportRect.x - contentRect.x) / (contentRect.width - viewportRect.width); + const percentV = (contentRect.y + contentRect.height - viewportRect.y - viewportRect.height) / (contentRect.height - viewportRect.height) + const listeners = this._listeners.getLoopArray(); + for (let i = 0, n = listeners.length; i < n; i++) { + const listener = listeners[i]; + !listener.destroyed && listener.fn(percentH, percentV); + } + } + + override onPointerEndDrag(eventData: PointerEventData): void { + this._isDragging = false; + } + + override onUpdate(deltaTime: number): void { + if (this._isDragging || !this._inertia) return; + const velocity = this._velocity; + if (velocity.lengthSquared() <= this._threshold ** 2) { + velocity.set(0, 0); + return; + } + let dx = 0; + let dy = 0; + const { _inertiaBaseTimeInterval: inertiaBaseTimeInterval, _decelerationRate: decelerationRate } = this; + while (deltaTime > 0) { + const timeSlice = deltaTime > inertiaBaseTimeInterval ? inertiaBaseTimeInterval : deltaTime; + velocity.scale(Math.pow(decelerationRate, timeSlice)); + dx += velocity.x * inertiaBaseTimeInterval; + dy += velocity.y * inertiaBaseTimeInterval; + deltaTime -= inertiaBaseTimeInterval; + } + this._updateRect(); + if (this._movementType !== MovementType.Unrestricted) { + const { _contentRect: contentRect, _viewportRect: viewportRect } = this; + if (dx > 0) { + if (contentRect.x + dx > viewportRect.x) { + dx = viewportRect.x - contentRect.x; + velocity.x = 0; + } + } else if (dx < 0) { + if (contentRect.x + contentRect.width + dx < viewportRect.x + viewportRect.width) { + dx = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; + velocity.x = 0; + } + } + if (dy > 0) { + if (contentRect.y + dy > viewportRect.y) { + dy = viewportRect.y - contentRect.y; + velocity.y = 0; + } + } else if (dy < 0) { + if (contentRect.y + contentRect.height + dy < viewportRect.y + viewportRect.height) { + dy = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; + velocity.y = 0; + } + } + } + this._applyScrolling(dx, dy); + + } + + private _updateRect(): void { + const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; + if (viewportTransform && contentTransform) { + const { _viewportRect: viewportRect, _contentRect: contentRect } = this; + // viewport 的 localRect + const viewportPivot = viewportTransform.pivot; + const viewportSize = viewportTransform.size; + viewportRect.set(-viewportPivot.x * viewportSize.x, -viewportPivot.y * viewportSize.y, viewportSize.x, viewportSize.y); + // content 相对于 viewport 的 Rect + const contentPosition = contentTransform.position; + const contentScale = contentTransform.scale; + const contentPivot = contentTransform.pivot; + const contentSize = contentTransform.size; + contentRect.set( + contentPosition.x - contentPivot.x * contentSize.x * contentScale.x, + contentPosition.y - contentPivot.y * contentSize.y * contentScale.y, + contentSize.x * contentScale.x, + contentSize.y * contentScale.y + ); + } + } + + override onDestroy(): void { + super.onDestroy(); + this._listeners.findAndRemove((value) => (value.destroyed = true)); + } +} + +export enum MovementType { + Elastic, + Clamped, + Unrestricted +} + +export interface IScrollListener { + fn: (percentH: number, percentV: number) => void; + destroyed?: boolean; +} + +export enum ScrollViewMode { + Vertical = 0x1, + Horizontal = 0x2, + VerticalAndHorizontal = 0x3 +} \ No newline at end of file diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index 3b20425eb6..b4b2f03cd9 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -205,17 +205,6 @@ export class Text extends UIRenderer implements ITextRenderer { } } - /** - * The mask layer the sprite renderer belongs to. - */ - get maskLayer(): number { - return this._maskLayer; - } - - set maskLayer(value: number) { - this._maskLayer = value; - } - /** * The bounding volume of the TextRenderer. */ diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts index 1f89431265..bf1fa00d04 100644 --- a/packages/ui/src/component/index.ts +++ b/packages/ui/src/component/index.ts @@ -1,11 +1,13 @@ -export { UICanvas } from "./UICanvas"; -export { UIGroup } from "./UIGroup"; -export { UIRenderer } from "./UIRenderer"; -export { UITransform } from "./UITransform"; export { Button } from "./advanced/Button"; export { Image } from "./advanced/Image"; +export { Mask } from "./advanced/Mask"; +export { ScrollView } from "./advanced/ScrollView"; export { Text } from "./advanced/Text"; export { ColorTransition } from "./interactive/transition/ColorTransition"; export { ScaleTransition } from "./interactive/transition/ScaleTransition"; export { SpriteTransition } from "./interactive/transition/SpriteTransition"; export { Transition } from "./interactive/transition/Transition"; +export { UICanvas } from "./UICanvas"; +export { UIGroup } from "./UIGroup"; +export { UIRenderer } from "./UIRenderer"; +export { UITransform } from "./UITransform"; From 11bfc9d867099b96bf61760a6d65672d18bf1b24 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 27 Feb 2026 21:25:03 +0800 Subject: [PATCH 02/10] feat: add ui mask --- examples/src/ui-mask.ts | 164 +++++ packages/core/src/2d/sprite/SpriteMask.ts | 120 ++-- .../core/src/2d/sprite/SpriteMaskUtils.ts | 239 ++++++++ packages/core/src/2d/sprite/index.ts | 1 + .../core/src/RenderPipeline/BatchUtils.ts | 29 + .../core/src/RenderPipeline/MaskManager.ts | 85 ++- .../core/src/shaderlib/extra/text.fs.glsl | 33 +- .../core/src/shaderlib/extra/text.vs.glsl | 4 + packages/ui/src/component/UICanvas.ts | 23 +- packages/ui/src/component/UIRenderer.ts | 195 +++++- packages/ui/src/component/advanced/Mask.ts | 566 +++++++++--------- .../ui/src/component/advanced/RectMask2D.ts | 157 +++++ .../ui/src/component/advanced/ScrollView.ts | 8 +- packages/ui/src/component/index.ts | 1 + packages/ui/src/shader/uiDefault.fs.glsl | 31 +- packages/ui/src/shader/uiDefault.vs.glsl | 4 + tests/src/ui/Mask.test.ts | 147 +++++ tests/src/ui/RectMask2D.test.ts | 107 ++++ 18 files changed, 1581 insertions(+), 333 deletions(-) create mode 100644 examples/src/ui-mask.ts create mode 100644 packages/core/src/2d/sprite/SpriteMaskUtils.ts create mode 100644 packages/ui/src/component/advanced/RectMask2D.ts create mode 100644 tests/src/ui/Mask.test.ts create mode 100644 tests/src/ui/RectMask2D.test.ts diff --git a/examples/src/ui-mask.ts b/examples/src/ui-mask.ts new file mode 100644 index 0000000000..c954a2cf72 --- /dev/null +++ b/examples/src/ui-mask.ts @@ -0,0 +1,164 @@ +/** + * @title UI Mask Raycast + * @category UI + */ +import { + Camera, + Color, + Entity, + PointerEventData, + Script, + Sprite, + SpriteMaskInteraction, + Texture2D, + TextureFormat, + WebGLEngine +} from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +class ClickCounter extends Script { + label: string = ""; + counterText: Text = null; + private _count: number = 0; + + override onPointerClick(_eventData: PointerEventData): void { + this._count += 1; + if (this.counterText) { + this.counterText.text = `${this.label}: ${this._count}`; + } + } +} + +async function main() { + const engine = await WebGLEngine.create({ canvas: "canvas" }); + engine.canvas.resizeByClientSize(); + window.addEventListener("resize", () => engine.canvas.resizeByClientSize()); + + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(1000, 700); + + const solidSprite = createSolidSprite(engine); + const circleSprite = createCircleSprite(engine, 256); + + const panelSize = { width: 760, height: 420 }; + + const outsideEntity = canvasEntity.createChild("OutsidePanel"); + const outsideImage = outsideEntity.addComponent(Image); + (outsideEntity.transform as UITransform).size.set(panelSize.width, panelSize.height); + outsideImage.sprite = solidSprite; + outsideImage.color.set(0.2, 0.56, 0.96, 0.95); + outsideImage.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; + + const insideEntity = canvasEntity.createChild("InsidePanel"); + const insideImage = insideEntity.addComponent(Image); + (insideEntity.transform as UITransform).size.set(panelSize.width, panelSize.height); + insideImage.sprite = solidSprite; + insideImage.color.set(0.96, 0.36, 0.28, 0.95); + insideImage.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + + const maskEntity = canvasEntity.createChild("Mask"); + const maskTransform = maskEntity.transform as UITransform; + maskTransform.size.set(280, 280); + const mask = maskEntity.addComponent(Mask); + mask.sprite = circleSprite; + mask.alphaCutoff = 0.1; + + const maskPreviewEntity = maskEntity.createChild("MaskPreview"); + const maskPreview = maskPreviewEntity.addComponent(Image); + (maskPreviewEntity.transform as UITransform).size.set(280, 280); + maskPreview.sprite = circleSprite; + maskPreview.color.set(1, 1, 1, 0.22); + maskPreview.raycastEnabled = false; + + createLabel(canvasEntity, "Title", "UI Mask + Raycast", 300, 44, new Color(1, 1, 1, 1)); + createLabel( + canvasEntity, + "Hint", + "Click red center (inside mask) and blue edge (outside mask).", + 250, + 24, + new Color(1, 1, 1, 0.95) + ); + + const insideCountLabel = createLabel(canvasEntity, "InsideCount", "Inside hits: 0", -260, 30, new Color(1, 0.85, 0.8, 1)); + const outsideCountLabel = createLabel( + canvasEntity, + "OutsideCount", + "Outside hits: 0", + -305, + 30, + new Color(0.78, 0.9, 1, 1) + ); + + const insideCounter = insideEntity.addComponent(ClickCounter); + insideCounter.label = "Inside hits"; + insideCounter.counterText = insideCountLabel; + + const outsideCounter = outsideEntity.addComponent(ClickCounter); + outsideCounter.label = "Outside hits"; + outsideCounter.counterText = outsideCountLabel; + + engine.run(); +} + +main(); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1, TextureFormat.R8G8B8A8, false); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} + +function createCircleSprite(engine: WebGLEngine, size: number): Sprite { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create canvas 2D context."); + } + + context.clearRect(0, 0, size, size); + context.fillStyle = "#ffffff"; + context.beginPath(); + context.arc(size * 0.5, size * 0.5, size * 0.46, 0, Math.PI * 2); + context.closePath(); + context.fill(); + + const texture = new Texture2D(engine, size, size, TextureFormat.R8G8B8A8, false); + texture.setImageSource(canvas); + return new Sprite(engine, texture); +} + +function createLabel( + parent: Entity, + name: string, + content: string, + y: number, + fontSize: number, + color: Color +): Text { + const entity = parent.createChild(name); + const transform = entity.transform as UITransform; + transform.size.set(980, 64); + transform.setPosition(0, y, 0); + + const label = entity.addComponent(Text); + label.text = content; + label.fontSize = fontSize; + label.color = color; + label.raycastEnabled = false; + + return label; +} diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index b179899b8e..693e72a067 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -1,6 +1,5 @@ -import { BoundingBox } from "@galacean/engine-math"; +import { BoundingBox, Vector3 } from "@galacean/engine-math"; import { Entity } from "../../Entity"; -import { RenderQueueFlags } from "../../RenderPipeline/BasicRenderPipeline"; import { BatchUtils } from "../../RenderPipeline/BatchUtils"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; @@ -15,6 +14,7 @@ import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { Sprite } from "./Sprite"; +import { SpriteMaskUtils } from "./SpriteMaskUtils"; /** * A component for masking Sprites. @@ -27,7 +27,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { /** The mask layers the sprite mask influence to. */ @assignmentClone - influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; + private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; /** @internal */ @ignoreClone _renderElement: RenderElement; @@ -58,6 +58,22 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { @assignmentClone private _alphaCutoff: number = 0.5; + /** + * The mask layers the sprite mask influence to. + */ + get influenceLayers(): SpriteMaskLayer { + return this._influenceLayers; + } + + set influenceLayers(value: SpriteMaskLayer) { + if (this._influenceLayers !== value) { + this._influenceLayers = value; + if (this._phasedActiveInScene) { + this.scene._maskManager.onMaskInfluenceLayersChange(); + } + } + } + /** * Render width (in world coordinates). * @@ -140,22 +156,14 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { } set sprite(value: Sprite | null) { - const lastSprite = this._sprite; - if (lastSprite !== value) { - if (lastSprite) { - this._addResourceReferCount(lastSprite, -1); - lastSprite._updateFlagManager.removeListener(this._onSpriteChange); - } - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.All; - if (value) { - this._addResourceReferCount(value, 1); - value._updateFlagManager.addListener(this._onSpriteChange); - this.shaderData.setTexture(SpriteMask._textureProperty, value.texture); - } else { - this.shaderData.setTexture(SpriteMask._textureProperty, null); - } - this._sprite = value; - } + this._sprite = SpriteMaskUtils.setSprite( + this, + this._sprite, + value, + this._onSpriteChange, + SpriteMask._textureProperty, + SpriteMaskUpdateFlags.All + ); } /** @@ -238,23 +246,39 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { return this.engine._batcherManager.primitiveChunkManagerMask; } + /** + * @internal + */ + _containsWorldPoint(worldPoint: Vector3): boolean { + const { _sprite: sprite } = this; + return SpriteMaskUtils.containsWorldPoint( + worldPoint, + sprite, + this._transformEntity.transform.worldMatrix, + this.width, + this.height, + sprite?.pivot, + this._flipX, + this._flipY, + this._alphaCutoff + ); + } + protected override _updateBounds(worldBounds: BoundingBox): void { const sprite = this._sprite; - if (sprite) { - SimpleSpriteAssembler.updatePositions( - this, - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY - ); - } else { - const { worldPosition } = this._transformEntity.transform; - worldBounds.min.copyFrom(worldPosition); - worldBounds.max.copyFrom(worldPosition); - } + const transform = this._transformEntity.transform; + SpriteMaskUtils.updateBounds( + this, + sprite, + worldBounds, + transform.worldMatrix, + transform.worldPosition, + this.width, + this.height, + sprite?.pivot, + this._flipX, + this._flipY + ); } /** @@ -278,7 +302,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { // Update position if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - SimpleSpriteAssembler.updatePositions( + SpriteMaskUtils.updatePositions( this, this._transformEntity.transform.worldMatrix, this.width, @@ -292,30 +316,24 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { // Update uv if (this._dirtyUpdateFlag & SpriteMaskUpdateFlags.UV) { - SimpleSpriteAssembler.updateUVs(this); + SpriteMaskUtils.updateUVs(this); this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.UV; } - - const renderElement = this._renderElement; - const subRenderElement = renderElement.subRenderElements[0]; - renderElement.set(this.priority, this._distanceForSort); - - const subChunk = this._subChunk; - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - renderElement.addSubRenderElement(subRenderElement); + SpriteMaskUtils.setupRenderElement( + this._renderElement, + this, + material, + this._subChunk, + this.sprite.texture, + this._distanceForSort + ); } /** * @inheritdoc */ protected override _onDestroy(): void { - const sprite = this._sprite; - if (sprite) { - this._addResourceReferCount(sprite, -1); - sprite._updateFlagManager.removeListener(this._onSpriteChange); - } + SpriteMaskUtils.releaseSprite(this, this._sprite, this._onSpriteChange); super._onDestroy(); diff --git a/packages/core/src/2d/sprite/SpriteMaskUtils.ts b/packages/core/src/2d/sprite/SpriteMaskUtils.ts new file mode 100644 index 0000000000..8a78060399 --- /dev/null +++ b/packages/core/src/2d/sprite/SpriteMaskUtils.ts @@ -0,0 +1,239 @@ +import { BoundingBox, Matrix, Vector2, Vector3 } from "@galacean/engine-math"; +import { Renderer } from "../../Renderer"; +import { RenderQueueFlags } from "../../RenderPipeline/BasicRenderPipeline"; +import { RenderElement } from "../../RenderPipeline/RenderElement"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { ShaderProperty } from "../../shader/ShaderProperty"; +import { Material } from "../../material"; +import { Texture2D, TextureFormat } from "../../texture"; +import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; +import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; +import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; +import { Sprite } from "./Sprite"; + +/** + * Internal helpers shared by SpriteMask and UI Mask. + * @internal + */ +export class SpriteMaskUtils { + private static _tempMat: Matrix = new Matrix(); + private static _tempVec3: Vector3 = new Vector3(); + private static _u8Buffer1 = new Uint8Array(1); + private static _u8Buffer2 = new Uint8Array(2); + private static _u8Buffer4 = new Uint8Array(4); + private static _u16Buffer1 = new Uint16Array(1); + private static _u16Buffer4 = new Uint16Array(4); + private static _f32Buffer4 = new Float32Array(4); + private static _u32Buffer4 = new Uint32Array(4); + + static setSprite( + renderer: Renderer, + currentSprite: Sprite | null, + nextSprite: Sprite | null, + onSpriteChange: (type: SpriteModifyFlags) => void, + textureProperty: ShaderProperty, + allDirtyFlag: number + ): Sprite | null { + if (currentSprite === nextSprite) { + return currentSprite; + } + + const target = renderer as any; + if (currentSprite) { + target._addResourceReferCount(currentSprite, -1); + currentSprite._updateFlagManager.removeListener(onSpriteChange); + } + + target._dirtyUpdateFlag |= allDirtyFlag; + if (nextSprite) { + target._addResourceReferCount(nextSprite, 1); + nextSprite._updateFlagManager.addListener(onSpriteChange); + renderer.shaderData.setTexture(textureProperty, nextSprite.texture); + } else { + renderer.shaderData.setTexture(textureProperty, null); + } + + return nextSprite; + } + + static releaseSprite( + renderer: Renderer, + sprite: Sprite | null, + onSpriteChange: (type: SpriteModifyFlags) => void + ): void { + if (sprite) { + const target = renderer as any; + target._addResourceReferCount(sprite, -1); + sprite._updateFlagManager.removeListener(onSpriteChange); + } + } + + static updateBounds( + renderer: ISpriteRenderer, + sprite: Sprite | null, + worldBounds: BoundingBox, + worldMatrix: Matrix, + worldPosition: Vector3, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean + ): void { + if (sprite) { + SpriteMaskUtils.updatePositions(renderer, worldMatrix, width, height, pivot, flipX, flipY); + } else { + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } + } + + static updatePositions( + renderer: ISpriteRenderer, + worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean + ): void { + SimpleSpriteAssembler.updatePositions(renderer, worldMatrix, width, height, pivot, flipX, flipY); + } + + static updateUVs(renderer: ISpriteRenderer): void { + SimpleSpriteAssembler.updateUVs(renderer); + } + + static setupRenderElement( + renderElement: RenderElement, + renderer: Renderer, + material: Material, + subChunk: SubPrimitiveChunk, + texture: any, + distanceForSort: number + ): void { + const subRenderElement = renderElement.subRenderElements[0]; + renderElement.set(renderer.priority, distanceForSort); + subRenderElement.set(renderer, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; + renderElement.addSubRenderElement(subRenderElement); + } + + static containsWorldPoint( + worldPoint: Vector3, + sprite: Sprite | null, + worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean, + alphaCutoff: number = 0 + ): boolean { + if (!sprite || !width || !height) { + return false; + } + + const worldMatrixInv = SpriteMaskUtils._tempMat; + Matrix.invert(worldMatrix, worldMatrixInv); + const localPosition = SpriteMaskUtils._tempVec3; + Vector3.transformCoordinate(worldPoint, worldMatrixInv, localPosition); + + const sx = flipX ? -width : width; + const sy = flipY ? -height : height; + if (!sx || !sy) { + return false; + } + + const spriteX = localPosition.x / sx + pivot.x; + const spriteY = localPosition.y / sy + pivot.y; + const spritePositions = sprite._getPositions(); + const { x: left, y: bottom } = spritePositions[0]; + const { x: right, y: top } = spritePositions[3]; + if (!(spriteX >= left && spriteX <= right && spriteY >= bottom && spriteY <= top)) { + return false; + } + + if (alphaCutoff <= 0) { + return true; + } + + const texture = sprite.texture; + if (!texture) { + return false; + } + + const spriteUVs = sprite._getUVs(); + const leftU = spriteUVs[0].x; + const bottomV = spriteUVs[0].y; + const rightU = spriteUVs[3].x; + const topV = spriteUVs[3].y; + const positionWidth = right - left; + const positionHeight = top - bottom; + if (!positionWidth || !positionHeight) { + return false; + } + + const tx = (spriteX - left) / positionWidth; + const ty = (spriteY - bottom) / positionHeight; + const u = leftU + (rightU - leftU) * tx; + const v = bottomV + (topV - bottomV) * ty; + const x = Math.min(Math.max(Math.floor(u * texture.width), 0), texture.width - 1); + const y = Math.min(Math.max(Math.floor(v * texture.height), 0), texture.height - 1); + return SpriteMaskUtils._sampleTextureAlpha(texture, x, y) >= alphaCutoff; + } + + private static _sampleTextureAlpha(texture: Texture2D, x: number, y: number): number { + try { + switch (texture.format) { + case TextureFormat.R8G8B8A8: { + const buffer = SpriteMaskUtils._u8Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 255; + } + case TextureFormat.R4G4B4A4: { + const buffer = SpriteMaskUtils._u16Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return (buffer[0] & 0xf) / 15; + } + case TextureFormat.R5G5B5A1: { + const buffer = SpriteMaskUtils._u16Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[0] & 0x1; + } + case TextureFormat.Alpha8: + case TextureFormat.R8: { + const buffer = SpriteMaskUtils._u8Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[0] / 255; + } + case TextureFormat.LuminanceAlpha: + case TextureFormat.R8G8: { + const buffer = SpriteMaskUtils._u8Buffer2; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[1] / 255; + } + case TextureFormat.R16G16B16A16: { + const buffer = SpriteMaskUtils._u16Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 65535; + } + case TextureFormat.R32G32B32A32: { + const buffer = SpriteMaskUtils._f32Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3]; + } + case TextureFormat.R32G32B32A32_UInt: { + const buffer = SpriteMaskUtils._u32Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 4294967295; + } + default: + return 1; + } + } catch { + return 1; + } + } +} diff --git a/packages/core/src/2d/sprite/index.ts b/packages/core/src/2d/sprite/index.ts index 162d016472..a64ff00b87 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -1,3 +1,4 @@ export { Sprite } from "./Sprite"; export { SpriteMask } from "./SpriteMask"; +export { SpriteMaskUtils } from "./SpriteMaskUtils"; export { SpriteRenderer } from "./SpriteRenderer"; diff --git a/packages/core/src/RenderPipeline/BatchUtils.ts b/packages/core/src/RenderPipeline/BatchUtils.ts index 387c951f93..14c203ee86 100644 --- a/packages/core/src/RenderPipeline/BatchUtils.ts +++ b/packages/core/src/RenderPipeline/BatchUtils.ts @@ -19,6 +19,35 @@ export class BatchUtils { const rendererA = elementA.component; const rendererB = elementB.component; const maskInteractionA = rendererA.maskInteraction; + const rendererAAny = rendererA as any; + const rendererBAny = rendererB as any; + const rectMaskEnabledA = rendererAAny._rectMaskEnabled; + if (rectMaskEnabledA !== rendererBAny._rectMaskEnabled) { + return false; + } + if (rectMaskEnabledA) { + const rectMaskRectA = rendererAAny._rectMaskRect; + const rectMaskRectB = rendererBAny._rectMaskRect; + const rectMaskSoftnessA = rendererAAny._rectMaskSoftness; + const rectMaskSoftnessB = rendererBAny._rectMaskSoftness; + if ( + !rectMaskRectA || + !rectMaskRectB || + !rectMaskSoftnessA || + !rectMaskSoftnessB || + rectMaskRectA.x !== rectMaskRectB.x || + rectMaskRectA.y !== rectMaskRectB.y || + rectMaskRectA.z !== rectMaskRectB.z || + rectMaskRectA.w !== rectMaskRectB.w || + rectMaskSoftnessA.x !== rectMaskSoftnessB.x || + rectMaskSoftnessA.y !== rectMaskSoftnessB.y || + rectMaskSoftnessA.z !== rectMaskSoftnessB.z || + rectMaskSoftnessA.w !== rectMaskSoftnessB.w || + rendererAAny._rectMaskHardClip !== rendererBAny._rectMaskHardClip + ) { + return false; + } + } // Compare mask, texture and material return ( diff --git a/packages/core/src/RenderPipeline/MaskManager.ts b/packages/core/src/RenderPipeline/MaskManager.ts index 8454e1d584..98c962b683 100644 --- a/packages/core/src/RenderPipeline/MaskManager.ts +++ b/packages/core/src/RenderPipeline/MaskManager.ts @@ -1,4 +1,5 @@ -import { SpriteMask } from "../2d"; +import { Vector3 } from "@galacean/engine-math"; +import { SpriteMaskInteraction } from "../2d/enums/SpriteMaskInteraction"; import { CameraClearFlags } from "../enums/CameraClearFlags"; import { SpriteMaskLayer } from "../enums/SpriteMaskLayer"; import { Material } from "../material"; @@ -7,9 +8,21 @@ import { RenderQueueType } from "../shader/enums/RenderQueueType"; import { StencilOperation } from "../shader/enums/StencilOperation"; import { DisorderedArray } from "../utils/DisorderedArray"; import { RenderContext } from "./RenderContext"; +import { RenderElement } from "./RenderElement"; import { RenderQueue } from "./RenderQueue"; import { RenderQueueMaskType } from "./enums/RenderQueueMaskType"; +/** + * @internal + */ +export interface IMask { + influenceLayers: SpriteMaskLayer; + _renderElement: RenderElement; + _maskIndex: number; + + _containsWorldPoint(worldPoint: Vector3): boolean; +} + /** * @internal */ @@ -28,17 +41,49 @@ export class MaskManager { hasStencilWritten = false; private _preMaskLayer = SpriteMaskLayer.Nothing; - private _allSpriteMasks = new DisorderedArray(); + private _allSpriteMasks = new DisorderedArray(); + private _filteredMasksByLayer = new Map(); + private _isFilteredMasksDirty = true; - addSpriteMask(mask: SpriteMask): void { + addSpriteMask(mask: IMask): void { mask._maskIndex = this._allSpriteMasks.length; this._allSpriteMasks.add(mask); + this._setFilteredMasksDirty(); } - removeSpriteMask(mask: SpriteMask): void { + removeSpriteMask(mask: IMask): void { const replaced = this._allSpriteMasks.deleteByIndex(mask._maskIndex); replaced && (replaced._maskIndex = mask._maskIndex); mask._maskIndex = -1; + this._setFilteredMasksDirty(); + } + + onMaskInfluenceLayersChange(): void { + this._setFilteredMasksDirty(); + } + + isVisibleByMask(maskInteraction: SpriteMaskInteraction, maskLayer: SpriteMaskLayer, worldPoint: Vector3): boolean { + if (maskInteraction === SpriteMaskInteraction.None) { + return true; + } + + const masks = this._getMasksByLayer(maskLayer); + let insideMask = false; + for (let i = 0, n = masks.length; i < n; i++) { + if (masks[i]._containsWorldPoint(worldPoint)) { + insideMask = true; + break; + } + } + + switch (maskInteraction) { + case SpriteMaskInteraction.VisibleInsideMask: + return insideMask; + case SpriteMaskInteraction.VisibleOutsideMask: + return !insideMask; + default: + return true; + } } drawMask(context: RenderContext, pipelineStageTagValue: string, maskLayer: SpriteMaskLayer): void { @@ -118,6 +163,38 @@ export class MaskManager { const allSpriteMasks = this._allSpriteMasks; allSpriteMasks.length = 0; allSpriteMasks.garbageCollection(); + this._filteredMasksByLayer.clear(); + this._isFilteredMasksDirty = true; + } + + private _setFilteredMasksDirty(): void { + this._isFilteredMasksDirty = true; + } + + private _getMasksByLayer(maskLayer: SpriteMaskLayer): IMask[] { + if (maskLayer === SpriteMaskLayer.Nothing) { + return []; + } + + if (this._isFilteredMasksDirty) { + this._filteredMasksByLayer.clear(); + this._isFilteredMasksDirty = false; + } + + let filteredMasks = this._filteredMasksByLayer.get(maskLayer); + if (!filteredMasks) { + filteredMasks = []; + const allMasks = this._allSpriteMasks; + const maskElements = allMasks._elements; + for (let i = 0, n = allMasks.length; i < n; i++) { + const mask = maskElements[i]; + if (mask.influenceLayers & maskLayer) { + filteredMasks.push(mask); + } + } + this._filteredMasksByLayer.set(maskLayer, filteredMasks); + } + return filteredMasks; } private _buildMaskRenderElement( diff --git a/packages/core/src/shaderlib/extra/text.fs.glsl b/packages/core/src/shaderlib/extra/text.fs.glsl index 8fe1125d69..019d419ba4 100644 --- a/packages/core/src/shaderlib/extra/text.fs.glsl +++ b/packages/core/src/shaderlib/extra/text.fs.glsl @@ -1,15 +1,46 @@ uniform sampler2D renderElement_TextTexture; +uniform vec4 renderer_UIRectClipRect; +uniform float renderer_UIRectClipEnabled; +uniform vec4 renderer_UIRectClipSoftness; +uniform float renderer_UIRectClipHardClip; varying vec2 v_uv; varying vec4 v_color; +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; +} void main() { + float rectClipAlpha = 1.0; + if (renderer_UIRectClipEnabled > 0.5) { + rectClipAlpha = getUIRectClipAlpha(); + } + vec4 texColor = texture2D(renderElement_TextTexture, v_uv); #ifdef GRAPHICS_API_WEBGL2 float coverage = texColor.r; #else float coverage = texColor.a; #endif - gl_FragColor = vec4(v_color.rgb, v_color.a * coverage); + vec4 finalColor = vec4(v_color.rgb, v_color.a * coverage); + finalColor.a *= rectClipAlpha; + if (renderer_UIRectClipEnabled > 0.5 && renderer_UIRectClipHardClip > 0.5 && finalColor.a < 0.001) { + discard; + } + gl_FragColor = finalColor; } diff --git a/packages/core/src/shaderlib/extra/text.vs.glsl b/packages/core/src/shaderlib/extra/text.vs.glsl index 37a6b2d333..b690be7fa3 100644 --- a/packages/core/src/shaderlib/extra/text.vs.glsl +++ b/packages/core/src/shaderlib/extra/text.vs.glsl @@ -1,4 +1,5 @@ uniform mat4 renderer_MVPMat; +uniform mat4 renderer_ModelMat; attribute vec3 POSITION; attribute vec2 TEXCOORD_0; @@ -6,11 +7,14 @@ attribute vec4 COLOR_0; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; void main() { + vec4 worldPosition = renderer_ModelMat * vec4(POSITION, 1.0); gl_Position = renderer_MVPMat * vec4(POSITION, 1.0); v_uv = TEXCOORD_0; v_color = COLOR_0; + v_worldPosition = worldPosition.xy; } diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index a47e912ec1..64c0420fac 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -24,6 +24,7 @@ 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"; import { UITransform } from "./UITransform"; @@ -39,6 +40,7 @@ export class UICanvas extends Component implements IElement { static _hierarchyCounter: number = 1; private static _targetTempPath: number[] = []; private static _tempGroupAbleList: IGroupAble[] = []; + private static _tempRectMaskList: RectMask2D[] = []; private static _tempVec3: Vector3 = new Vector3(); private static _tempMat: Matrix = new Matrix(); @@ -432,7 +434,8 @@ export class UICanvas extends Component implements IElement { const { _orderedRenderers: renderers, entity } = this; const uiHierarchyVersion = entity._uiHierarchyVersion; if (this._hierarchyVersion !== uiHierarchyVersion) { - renderers.length = this._walk(this.entity, renderers); + UICanvas._tempRectMaskList.length = 0; + renderers.length = this._walk(this.entity, renderers, 0, null, 0); UICanvas._tempGroupAbleList.length = 0; this._hierarchyVersion = uiHierarchyVersion; ++UICanvas._hierarchyCounter; @@ -514,10 +517,18 @@ export class UICanvas extends Component implements IElement { transform.size.set(curWidth / expectX, curHeight / expectY); } - private _walk(entity: Entity, renderers: UIRenderer[], depth = 0, group: UIGroup = null): number { + private _walk( + entity: Entity, + renderers: UIRenderer[], + depth = 0, + group: UIGroup = null, + rectMaskCount: number = 0 + ): number { // @ts-ignore const components: Component[] = entity._components; const tempGroupAbleList = UICanvas._tempGroupAbleList; + const tempRectMaskList = UICanvas._tempRectMaskList; + let rectMask: RectMask2D = null; let groupAbleCount = 0; for (let i = 0, n = components.length; i < n; i++) { const component = components[i]; @@ -529,11 +540,14 @@ export class UICanvas extends Component implements IElement { if (component._isGroupDirty) { tempGroupAbleList[groupAbleCount++] = component; } + component._setRectMasks(tempRectMaskList, rectMaskCount); } else if (component instanceof UIInteractive) { component._isRootCanvasDirty && Utils.setRootCanvas(component, this); if (component._isGroupDirty) { tempGroupAbleList[groupAbleCount++] = component; } + } else if (component instanceof RectMask2D) { + rectMask = component; } else if (component instanceof UIGroup) { component._isRootCanvasDirty && Utils.setRootCanvas(component, this); component._isGroupDirty && Utils.setGroup(component, group); @@ -543,10 +557,13 @@ export class UICanvas extends Component implements IElement { for (let i = 0; i < groupAbleCount; i++) { Utils.setGroup(tempGroupAbleList[i], group); } + if (rectMask) { + tempRectMaskList[rectMaskCount++] = rectMask; + } const children = entity.children; for (let i = 0, n = children.length; i < n; i++) { const child = children[i]; - child.isActive && (depth = this._walk(child, renderers, depth, group)); + child.isActive && (depth = this._walk(child, renderers, depth, group, rectMaskCount)); } return depth; } diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index afaed177a2..f4216843fa 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -23,6 +23,7 @@ import { import { Utils } from "../Utils"; import { UIHitResult } from "../input/UIHitResult"; import { IGraphics } from "../interface/IGraphics"; +import { RectMask2D } from "./advanced/RectMask2D"; import { EntityUIModifyFlags, UICanvas } from "./UICanvas"; import { GroupModifyFlags, UIGroup } from "./UIGroup"; import { UITransform } from "./UITransform"; @@ -39,6 +40,16 @@ 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(); /** * Custom boundary for raycast detection. @@ -73,6 +84,21 @@ export class UIRenderer extends Renderer implements IGraphics { /** @internal */ @ignoreClone _subChunk; + /** @internal */ + @ignoreClone + _rectMasks: RectMask2D[] = []; + /** @internal */ + @ignoreClone + _rectMaskRect: Vector4 = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskEnabled: boolean = false; + /** @internal */ + @ignoreClone + _rectMaskSoftness: Vector4 = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskHardClip: boolean = false; @assignmentClone private _raycastEnabled: boolean = true; @@ -92,7 +118,6 @@ export class UIRenderer extends Renderer implements IGraphics { } } - /** * The mask layer the sprite renderer belongs to. */ @@ -139,6 +164,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); } // @ts-ignore @@ -164,6 +192,7 @@ export class UIRenderer extends Renderer implements IGraphics { this._update(context); } + this._updateRectMaskClipState(); this._render(context); // union camera global macro and renderer macro. @@ -266,6 +295,17 @@ export class UIRenderer extends Renderer implements IGraphics { return this.engine._batcherManager.primitiveChunkManagerUI; } + /** + * @internal + */ + _setRectMasks(rectMasks: RectMask2D[], count: number): void { + const targetMasks = this._rectMasks; + targetMasks.length = count; + for (let i = 0; i < count; i++) { + targetMasks[i] = rectMasks[i]; + } + } + /** * @internal */ @@ -281,7 +321,11 @@ export class UIRenderer extends Renderer implements IGraphics { Matrix.invert(transform.worldMatrix, worldMatrixInv); const localPosition = UIRenderer._tempVec31; Vector3.transformCoordinate(hitPointWorld, worldMatrixInv, localPosition); - if (this._hitTest(localPosition)) { + if ( + this._hitTest(localPosition) && + this._isRaycastVisibleByRectMask(hitPointWorld) && + this._isRaycastVisibleByMask(hitPointWorld) + ) { out.component = this; out.distance = curDistance; out.entity = this.entity; @@ -307,6 +351,151 @@ export class UIRenderer extends Renderer implements IGraphics { ); } + private _isRaycastVisibleByMask(hitPointWorld: Vector3): boolean { + const maskInteraction = this._maskInteraction; + if (maskInteraction === SpriteMaskInteraction.None) { + return true; + } + // @ts-ignore + return this.scene._maskManager.isVisibleByMask(maskInteraction, this._maskLayer, hitPointWorld); + } + + 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 _updateRectMaskClipState(): void { + const rectMasks = this._rectMasks; + const count = rectMasks.length; + if (count <= 0) { + 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); + } + 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) { + 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); + } + 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); + } + } + protected override _onDestroy(): void { if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); @@ -316,6 +505,8 @@ export class UIRenderer extends Renderer implements IGraphics { //@ts-ignore this._color._onValueChanged = null; this._color = null; + this._rectMasks = null; + this._rectMaskSoftness = null; } } diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts index 39921308ab..ef0ba98cd4 100644 --- a/packages/ui/src/component/advanced/Mask.ts +++ b/packages/ui/src/component/advanced/Mask.ts @@ -1,297 +1,329 @@ -import { assignmentClone, BatchUtils, BoundingBox, Entity, ignoreClone, ISpriteRenderer, RenderElement, RendererUpdateFlags, RenderQueueFlags, ShaderProperty, SimpleSpriteAssembler, Sprite, SpriteMaskLayer, SpriteModifyFlags, SubRenderElement } from "@galacean/engine"; +import { + assignmentClone, + BatchUtils, + BoundingBox, + Entity, + ignoreClone, + ISpriteRenderer, + RenderElement, + RendererUpdateFlags, + ShaderProperty, + SimpleSpriteAssembler, + Sprite, + SpriteMaskLayer, + SpriteMaskUtils, + SpriteModifyFlags, + SubRenderElement, + Vector3 +} from "@galacean/engine"; import { UIRenderer, UITransform } from ".."; export class Mask extends UIRenderer implements ISpriteRenderer { - /** @internal */ - static _maskTextureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); - /** @internal */ - static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - - /** The mask layers the sprite mask influence to. */ - @assignmentClone - influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; - /** @internal */ - @ignoreClone - _renderElement; - /** @internal */ - @ignoreClone - _maskIndex: number = -1; - - @ignoreClone - private _sprite: Sprite = null; - @assignmentClone - private _flipX: boolean = false; - @assignmentClone - private _flipY: boolean = false; - - @assignmentClone - private _alphaCutoff: number = 0.5; - - - /** - * @internal - */ - override _getChunkManager() { + /** @internal */ + static _maskTextureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); + /** @internal */ + static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + + /** The mask layers the sprite mask influence to. */ + @assignmentClone + private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; + /** @internal */ + @ignoreClone + _renderElement; + /** @internal */ + @ignoreClone + _maskIndex: number = -1; + + @ignoreClone + private _sprite: Sprite = null; + @assignmentClone + private _flipX: boolean = false; + @assignmentClone + private _flipY: boolean = false; + + @assignmentClone + private _alphaCutoff: number = 0.5; + + /** + * The mask layers the sprite mask influence to. + */ + get influenceLayers(): SpriteMaskLayer { + return this._influenceLayers; + } + + set influenceLayers(value: SpriteMaskLayer) { + if (this._influenceLayers !== value) { + this._influenceLayers = value; + if (this._phasedActiveInScene) { // @ts-ignore - return this.engine._batcherManager.primitiveChunkManagerMask; + this.scene._maskManager.onMaskInfluenceLayersChange(); + } } - - /** - * Flips the sprite on the X axis. - */ - get flipX(): boolean { - return this._flipX; - } - - set flipX(value: boolean) { - if (this._flipX !== value) { - this._flipX = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * Flips the sprite on the Y axis. - */ - get flipY(): boolean { - return this._flipY; - } - - set flipY(value: boolean) { - if (this._flipY !== value) { - this._flipY = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * The Sprite to render. - */ - get sprite(): Sprite { - return this._sprite; + } + + /** + * @internal + */ + override _getChunkManager() { + // @ts-ignore + return this.engine._batcherManager.primitiveChunkManagerMask; + } + + /** + * Flips the sprite on the X axis. + */ + get flipX(): boolean { + return this._flipX; + } + + set flipX(value: boolean) { + if (this._flipX !== value) { + this._flipX = value; + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } - - set sprite(value: Sprite | null) { - const lastSprite = this._sprite; - if (lastSprite !== value) { - if (lastSprite) { - this._addResourceReferCount(lastSprite, -1); - // @ts-ignore - lastSprite._updateFlagManager.removeListener(this._onSpriteChange); - } - this._dirtyUpdateFlag |= MaskUpdateFlags.All; - if (value) { - this._addResourceReferCount(value, 1); - // @ts-ignore - value._updateFlagManager.addListener(this._onSpriteChange); - this.shaderData.setTexture(Mask._maskTextureProperty, value.texture); - } else { - this.shaderData.setTexture(Mask._maskTextureProperty, null); - } - this._sprite = value; - } - } - - /** - * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. - */ - get alphaCutoff(): number { - return this._alphaCutoff; + } + + /** + * Flips the sprite on the Y axis. + */ + get flipY(): boolean { + return this._flipY; + } + + set flipY(value: boolean) { + if (this._flipY !== value) { + this._flipY = value; + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } - - set alphaCutoff(value: number) { - if (this._alphaCutoff !== value) { - this._alphaCutoff = value; - this.shaderData.setFloat(Mask._alphaCutoffProperty, value); - } + } + + /** + * The Sprite to render. + */ + get sprite(): Sprite { + return this._sprite; + } + + set sprite(value: Sprite | null) { + this._sprite = SpriteMaskUtils.setSprite( + this, + this._sprite, + value, + this._onSpriteChange, + Mask._maskTextureProperty, + MaskUpdateFlags.All + ); + } + + /** + * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. + */ + get alphaCutoff(): number { + return this._alphaCutoff; + } + + set alphaCutoff(value: number) { + if (this._alphaCutoff !== value) { + this._alphaCutoff = value; + this.shaderData.setFloat(Mask._alphaCutoffProperty, value); } - - - /** - * @internal - */ - override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { - return BatchUtils.canBatchSpriteMask(elementA, elementB); + } + + /** + * @internal + */ + override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { + return BatchUtils.canBatchSpriteMask(elementA, elementB); + } + + /** + * @internal + */ + override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { + BatchUtils.batchFor2D(elementA, elementB); + } + + /** + * @internal + */ + constructor(entity: Entity) { + super(entity); + SimpleSpriteAssembler.resetData(this); + // @ts-ignore + this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); + this.shaderData.setFloat(Mask._alphaCutoffProperty, this._alphaCutoff); + this._renderElement = new RenderElement(); + this._renderElement.addSubRenderElement(new SubRenderElement()); + this._onSpriteChange = this._onSpriteChange.bind(this); + this.raycastEnabled = false; + } + + /** + * @internal + */ + _cloneTo(target: Mask, srcRoot: Entity, targetRoot: Entity): void { + // @ts-ignore + super._cloneTo(target, srcRoot, targetRoot); + target.sprite = this._sprite; + } + + /** + * @internal + */ + override _onEnableInScene(): void { + super._onEnableInScene(); + // @ts-ignore + this.scene._maskManager.addSpriteMask(this); + } + + /** + * @internal + */ + override _onDisableInScene(): void { + super._onDisableInScene(); + // @ts-ignore + this.scene._maskManager.removeSpriteMask(this); + } + + /** + * @internal + */ + _containsWorldPoint(worldPoint: Vector3): boolean { + const { _sprite: sprite } = this; + const transform = this._transformEntity.transform; + const { x: width, y: height } = transform.size; + return SpriteMaskUtils.containsWorldPoint( + worldPoint, + sprite, + transform.worldMatrix, + width, + height, + transform.pivot, + this._flipX, + this._flipY, + this._alphaCutoff + ); + } + + protected override _updateBounds(worldBounds: BoundingBox): void { + const sprite = this._sprite; + const rootCanvas = this._getRootCanvas(); + if (sprite && rootCanvas) { + const transform = this._transformEntity.transform; + const { x: width, y: height } = transform.size; + SpriteMaskUtils.updateBounds( + this, + sprite, + worldBounds, + transform.worldMatrix, + transform.worldPosition, + width, + height, + transform.pivot, + this._flipX, + this._flipY + ); + } else { + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); } - - /** - * @internal - */ - override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { - BatchUtils.batchFor2D(elementA, elementB); + } + + /** + * @inheritdoc + */ + protected override _render(context): void { + const { _sprite: sprite } = this; + const transform = this._transformEntity.transform; + const { x: width, y: height } = transform.size; + if (!sprite?.texture || !width || !height) { + return; } - /** - * @internal - */ - constructor(entity: Entity) { - super(entity); - SimpleSpriteAssembler.resetData(this); - // @ts-ignore - this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); - this.shaderData.setFloat(Mask._alphaCutoffProperty, this._alphaCutoff); - this._renderElement = new RenderElement(); - this._renderElement.addSubRenderElement(new SubRenderElement()); - this._onSpriteChange = this._onSpriteChange.bind(this); - this.raycastEnabled = false; + let material = this.getMaterial(); + if (!material) { + return; } - - /** - * @internal - */ - _cloneTo(target: Mask, srcRoot: Entity, targetRoot: Entity): void { - // @ts-ignore - super._cloneTo(target, srcRoot, targetRoot); - target.sprite = this._sprite; + // @ts-ignore + const { _engine: engine } = this; + // @todo: This question needs to be raised rather than hidden. + if (material.destroyed) { + // @ts-ignore + material = engine._basicResources.spriteMaskDefaultMaterial; } - /** - * @internal - */ - override _onEnableInScene(): void { - super._onEnableInScene(); - // @ts-ignore - this.scene._maskManager.addSpriteMask(this); + // Update position + if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { + SpriteMaskUtils.updatePositions( + this, + transform.worldMatrix, + width, + height, + transform.pivot, + this._flipX, + this._flipY + ); + this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; } - /** - * @internal - */ - override _onDisableInScene(): void { - super._onDisableInScene(); - // @ts-ignore - this.scene._maskManager.removeSpriteMask(this); + // Update uv + if (this._dirtyUpdateFlag & MaskUpdateFlags.UV) { + SpriteMaskUtils.updateUVs(this); + this._dirtyUpdateFlag &= ~MaskUpdateFlags.UV; } + SpriteMaskUtils.setupRenderElement(this._renderElement, this, material, this._subChunk, this.sprite.texture, 0); + } - protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._sprite; - const rootCanvas = this._getRootCanvas(); - if (sprite && rootCanvas) { - const transform = this._transformEntity.transform; - const { size } = transform; - SimpleSpriteAssembler.updatePositions( - this, - transform.worldMatrix, - size.x, - size.y, - transform.pivot, - false, - false - ); - } else { - const { worldPosition } = this._transformEntity.transform; - worldBounds.min.copyFrom(worldPosition); - worldBounds.max.copyFrom(worldPosition); - } - } + /** + * @inheritdoc + */ + protected override _onDestroy(): void { + SpriteMaskUtils.releaseSprite(this, this._sprite, this._onSpriteChange); + this._sprite = null; - /** - * @inheritdoc - */ - protected override _render(context): void { - const { _sprite: sprite } = this; - const transform = this._transformEntity.transform; - const { x: width, y: height } = transform.size; - if (!sprite?.texture || !width || !height) { - return; - } - - let material = this.getMaterial(); - if (!material) { - return; - } - const { _engine: engine } = this; - // @todo: This question needs to be raised rather than hidden. - if (material.destroyed) { - // @ts-ignore - material = engine._basicResources.spriteMaskDefaultMaterial; - } - - // Update position - if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - SimpleSpriteAssembler.updatePositions( - this, - transform.worldMatrix, - width, - height, - transform.pivot, - false, - false, - ); - this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; - } - - // Update uv - if (this._dirtyUpdateFlag & MaskUpdateFlags.UV) { - SimpleSpriteAssembler.updateUVs(this); - this._dirtyUpdateFlag &= ~MaskUpdateFlags.UV; - } - - const renderElement = this._renderElement; - const subRenderElement = renderElement.subRenderElements[0]; - renderElement.set(this.priority, 0); - const subChunk = this._subChunk; - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - renderElement.addSubRenderElement(subRenderElement); - } + super._onDestroy(); - /** - * @inheritdoc - */ - protected override _onDestroy(): void { - const sprite = this._sprite; - if (sprite) { - this._addResourceReferCount(sprite, -1); - // @ts-ignore - sprite._updateFlagManager.removeListener(this._onSpriteChange); - this._sprite = null; - } - - super._onDestroy(); - - if (this._subChunk) { - this._getChunkManager().freeSubChunk(this._subChunk); - this._subChunk = null; - } - - this._renderElement = null; + if (this._subChunk) { + this._getChunkManager().freeSubChunk(this._subChunk); + this._subChunk = null; } - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { - switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(Mask._maskTextureProperty, this.sprite.texture); - break; - case SpriteModifyFlags.region: - case SpriteModifyFlags.atlasRegionOffset: - this._dirtyUpdateFlag |= MaskUpdateFlags.WorldVolumeAndUV; - break; - case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= MaskUpdateFlags.UV; - break; - case SpriteModifyFlags.destroy: - this.sprite = null; - break; - default: - break; - } + this._renderElement = null; + } + + @ignoreClone + private _onSpriteChange(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.texture: + this.shaderData.setTexture(Mask._maskTextureProperty, this.sprite.texture); + break; + case SpriteModifyFlags.region: + case SpriteModifyFlags.atlasRegionOffset: + this._dirtyUpdateFlag |= MaskUpdateFlags.WorldVolumeAndUV; + break; + case SpriteModifyFlags.atlasRegion: + this._dirtyUpdateFlag |= MaskUpdateFlags.UV; + break; + case SpriteModifyFlags.destroy: + this.sprite = null; + break; + default: + break; } + } } - /** * @remarks Extends `RendererUpdateFlags`. */ enum MaskUpdateFlags { - /** UV. */ - UV = 0x2, - /** Automatic Size. */ - AutomaticSize = 0x4, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** All. */ - All = 0x7 + /** UV. */ + UV = 0x2, + /** Automatic Size. */ + AutomaticSize = 0x4, + /** WorldVolume and UV. */ + WorldVolumeAndUV = 0x3, + /** All. */ + All = 0x7 } diff --git a/packages/ui/src/component/advanced/RectMask2D.ts b/packages/ui/src/component/advanced/RectMask2D.ts new file mode 100644 index 0000000000..7fd90c26a7 --- /dev/null +++ b/packages/ui/src/component/advanced/RectMask2D.ts @@ -0,0 +1,157 @@ +import { + Component, + DependentMode, + Entity, + Vector2, + Vector3, + Vector4, + assignmentClone, + deepClone, + dependentComponents +} from "@galacean/engine"; +import { UICanvas } from "../UICanvas"; +import { UITransform } from "../UITransform"; + +/** + * UI component that clips descendant graphics by an axis-aligned rectangle. + */ +@dependentComponents(UITransform, DependentMode.AutoAdd) +export class RectMask2D extends Component { + private static _tempRect: Vector4 = new Vector4(); + private static _tempCorner0: Vector3 = new Vector3(); + private static _tempCorner1: Vector3 = new Vector3(); + private static _tempCorner2: Vector3 = new Vector3(); + private static _tempCorner3: Vector3 = new Vector3(); + + @deepClone + private _softness: Vector2 = new Vector2(0, 0); + @assignmentClone + private _alphaClip: boolean = false; + + /** + * Soft clipping width on X/Y axis in world space. + */ + get softness(): Vector2 { + return this._softness; + } + + set softness(value: Vector2) { + const softness = this._softness; + if (softness === value) { + return; + } + if (softness.x !== value.x || softness.y !== value.y) { + softness.copyFrom(value); + this._clampSoftness(); + } + } + + /** + * Whether to enable hard clip (discard) when outside the rect. + */ + get alphaClip(): boolean { + return this._alphaClip; + } + + set alphaClip(value: boolean) { + this._alphaClip = value; + } + + /** + * @internal + */ + _getWorldRect(out: Vector4): boolean { + const transform = this.entity.transform; + const { x: width, y: height } = transform.size; + if (!width || !height) { + return false; + } + + const { x: pivotX, y: pivotY } = transform.pivot; + const left = -width * pivotX; + const right = width * (1 - pivotX); + const bottom = -height * pivotY; + const top = height * (1 - pivotY); + + const worldMatrix = transform.worldMatrix; + const corner0 = RectMask2D._tempCorner0; + const corner1 = RectMask2D._tempCorner1; + const corner2 = RectMask2D._tempCorner2; + const corner3 = RectMask2D._tempCorner3; + Vector3.transformCoordinate(corner0.set(left, bottom, 0), worldMatrix, corner0); + Vector3.transformCoordinate(corner1.set(left, top, 0), worldMatrix, corner1); + Vector3.transformCoordinate(corner2.set(right, bottom, 0), worldMatrix, corner2); + Vector3.transformCoordinate(corner3.set(right, top, 0), worldMatrix, corner3); + + const minX = Math.min(corner0.x, corner1.x, corner2.x, corner3.x); + const minY = Math.min(corner0.y, corner1.y, corner2.y, corner3.y); + const maxX = Math.max(corner0.x, corner1.x, corner2.x, corner3.x); + const maxY = Math.max(corner0.y, corner1.y, corner2.y, corner3.y); + out.set(minX, minY, maxX, maxY); + return true; + } + + /** + * @internal + */ + _containsWorldPoint(worldPoint: Vector3): boolean { + const worldRect = RectMask2D._tempRect; + if (!this._getWorldRect(worldRect)) { + return false; + } + const { x, y } = worldPoint; + return x >= worldRect.x && x <= worldRect.z && y >= worldRect.y && y <= worldRect.w; + } + + constructor(entity: Entity) { + super(entity); + this._onSoftnessChanged = this._onSoftnessChanged.bind(this); + // @ts-ignore + this._softness._onValueChanged = this._onSoftnessChanged; + } + + // @ts-ignore + override _onEnableInScene(): void { + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + } + + // @ts-ignore + override _onDisableInScene(): void { + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + } + + // @ts-ignore + override _cloneTo(target: RectMask2D, srcRoot: Entity, targetRoot: Entity): void { + // @ts-ignore + super._cloneTo(target, srcRoot, targetRoot); + + const targetSoftness = target._softness; + // @ts-ignore + targetSoftness._onValueChanged = null; + targetSoftness.copyFrom(this._softness); + target._clampSoftness(); + // @ts-ignore + targetSoftness._onValueChanged = target._onSoftnessChanged; + } + + protected override _onDestroy(): void { + // @ts-ignore + this._softness._onValueChanged = null; + this._softness = null; + super._onDestroy(); + } + + private _onSoftnessChanged(): void { + this._clampSoftness(); + } + + private _clampSoftness(): void { + const softness = this._softness; + if (softness.x < 0) { + softness.x = 0; + } + if (softness.y < 0) { + softness.y = 0; + } + } +} diff --git a/packages/ui/src/component/advanced/ScrollView.ts b/packages/ui/src/component/advanced/ScrollView.ts index 15fcf5823f..80c8b028ec 100644 --- a/packages/ui/src/component/advanced/ScrollView.ts +++ b/packages/ui/src/component/advanced/ScrollView.ts @@ -181,9 +181,9 @@ export class ScrollView extends Script { while (deltaTime > 0) { const timeSlice = deltaTime > inertiaBaseTimeInterval ? inertiaBaseTimeInterval : deltaTime; velocity.scale(Math.pow(decelerationRate, timeSlice)); - dx += velocity.x * inertiaBaseTimeInterval; - dy += velocity.y * inertiaBaseTimeInterval; - deltaTime -= inertiaBaseTimeInterval; + dx += velocity.x * timeSlice; + dy += velocity.y * timeSlice; + deltaTime -= timeSlice; } this._updateRect(); if (this._movementType !== MovementType.Unrestricted) { @@ -258,4 +258,4 @@ export enum ScrollViewMode { Vertical = 0x1, Horizontal = 0x2, VerticalAndHorizontal = 0x3 -} \ No newline at end of file +} diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts index bf1fa00d04..7eaa6ab248 100644 --- a/packages/ui/src/component/index.ts +++ b/packages/ui/src/component/index.ts @@ -1,6 +1,7 @@ export { Button } from "./advanced/Button"; export { Image } from "./advanced/Image"; export { Mask } from "./advanced/Mask"; +export { RectMask2D } from "./advanced/RectMask2D"; export { ScrollView } from "./advanced/ScrollView"; export { Text } from "./advanced/Text"; export { ColorTransition } from "./interactive/transition/ColorTransition"; diff --git a/packages/ui/src/shader/uiDefault.fs.glsl b/packages/ui/src/shader/uiDefault.fs.glsl index e4028405de..aa4ca2e0e9 100644 --- a/packages/ui/src/shader/uiDefault.fs.glsl +++ b/packages/ui/src/shader/uiDefault.fs.glsl @@ -1,14 +1,43 @@ #include uniform sampler2D renderer_UITexture; +uniform vec4 renderer_UIRectClipRect; +uniform float renderer_UIRectClipEnabled; +uniform vec4 renderer_UIRectClipSoftness; +uniform float renderer_UIRectClipHardClip; varying vec2 v_uv; varying vec4 v_color; +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; +} void main() { + float rectClipAlpha = 1.0; + if (renderer_UIRectClipEnabled > 0.5) { + rectClipAlpha = getUIRectClipAlpha(); + } + vec4 baseColor = texture2DSRGB(renderer_UITexture, v_uv); vec4 finalColor = baseColor * v_color; + finalColor.a *= rectClipAlpha; + if (renderer_UIRectClipEnabled > 0.5 && renderer_UIRectClipHardClip > 0.5 && finalColor.a < 0.001) { + discard; + } #ifdef ENGINE_SHOULD_SRGB_CORRECT finalColor = outputSRGBCorrection(finalColor); #endif gl_FragColor = finalColor; -} \ No newline at end of file +} diff --git a/packages/ui/src/shader/uiDefault.vs.glsl b/packages/ui/src/shader/uiDefault.vs.glsl index 2a6b45be4e..35c78a9cf2 100644 --- a/packages/ui/src/shader/uiDefault.vs.glsl +++ b/packages/ui/src/shader/uiDefault.vs.glsl @@ -1,4 +1,5 @@ uniform mat4 renderer_MVPMat; +uniform mat4 renderer_ModelMat; attribute vec3 POSITION; attribute vec2 TEXCOORD_0; @@ -6,10 +7,13 @@ attribute vec4 COLOR_0; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; void main() { + vec4 worldPosition = renderer_ModelMat * vec4(POSITION, 1.0); gl_Position = renderer_MVPMat * vec4(POSITION, 1.0); v_uv = TEXCOORD_0; v_color = COLOR_0; + v_worldPosition = worldPosition.xy; } diff --git a/tests/src/ui/Mask.test.ts b/tests/src/ui/Mask.test.ts new file mode 100644 index 0000000000..4fe175460d --- /dev/null +++ b/tests/src/ui/Mask.test.ts @@ -0,0 +1,147 @@ +import { PointerEventData, Script, Sprite, SpriteMaskInteraction, SpriteMaskLayer, Texture2D } from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { CanvasRenderMode, Image, Mask, UICanvas, UITransform } from "@galacean/engine-ui"; +import { describe, expect, it } from "vitest"; + +describe("Mask", async () => { + const body = document.getElementsByTagName("body")[0]; + const canvasDOM = document.createElement("canvas"); + canvasDOM.style.width = "18px"; + canvasDOM.style.height = "18px"; + body.appendChild(canvasDOM); + + const engine = await WebGLEngine.create({ canvas: canvasDOM }); + const webCanvas = engine.canvas; + webCanvas.width = 300; + webCanvas.height = 300; + const scene = engine.sceneManager.scenes[0]; + const root = scene.createRootEntity("root"); + const inputManager = engine.inputManager; + + const canvasEntity = root.createChild("canvas"); + const rootCanvas = canvasEntity.addComponent(UICanvas); + rootCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + rootCanvas.referenceResolutionPerUnit = 50; + rootCanvas.referenceResolution.set(300, 300); + + class ClickScript extends Script { + clickCount = 0; + + override onPointerClick(_eventData: PointerEventData): void { + ++this.clickCount; + } + } + + const imageEntity = canvasEntity.createChild("image"); + const image = imageEntity.addComponent(Image); + (imageEntity.transform).size.set(300, 300); + const clickScript = imageEntity.addComponent(ClickScript); + + const maskEntity = canvasEntity.createChild("mask"); + const mask = maskEntity.addComponent(Mask); + (maskEntity.transform).size.set(100, 100); + mask.sprite = createSolidSprite(engine); + + let pointerId = 0; + const clickAtNormalizedPosition = (x: number, y: number): void => { + // @ts-ignore + const { _pointerManager: pointerManager } = inputManager; + const { _target: target } = pointerManager; + const rect = target.getBoundingClientRect(); + const clientX = rect.left + rect.width * x; + const clientY = rect.top + rect.height * y; + const id = ++pointerId; + target.dispatchEvent(generatePointerEvent("pointerdown", id, clientX, clientY)); + engine.update(); + target.dispatchEvent(generatePointerEvent("pointerup", id, clientX, clientY)); + engine.update(); + }; + + it("should only raycast the inside area for VisibleInsideMask", () => { + image.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + clickScript.clickCount = 0; + + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(0); + + clickAtNormalizedPosition(0.5, 0.5); + expect(clickScript.clickCount).toBe(1); + }); + + it("should only raycast the outside area for VisibleOutsideMask", () => { + image.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; + clickScript.clickCount = 0; + + clickAtNormalizedPosition(0.5, 0.5); + expect(clickScript.clickCount).toBe(0); + + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(1); + }); + + it("should update raycast result when mask influence layer changes", () => { + image.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + clickScript.clickCount = 0; + + mask.influenceLayers = SpriteMaskLayer.Layer1; + clickAtNormalizedPosition(0.5, 0.5); + expect(clickScript.clickCount).toBe(0); + + mask.influenceLayers = SpriteMaskLayer.Layer0; + clickAtNormalizedPosition(0.5, 0.5); + expect(clickScript.clickCount).toBe(1); + }); + + it("should use mask sprite alpha for raycast visibility", () => { + image.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + mask.sprite = createCircleSprite(engine, 256); + mask.alphaCutoff = 0.1; + clickScript.clickCount = 0; + + // Inside mask rect but outside circle alpha. + clickAtNormalizedPosition(0.37, 0.37); + expect(clickScript.clickCount).toBe(0); + + // Circle center should still hit. + clickAtNormalizedPosition(0.5, 0.5); + expect(clickScript.clickCount).toBe(1); + }); +}); + +function generatePointerEvent( + type: string, + pointerId: number, + clientX: number, + clientY: number, + button: number = 0, + buttons: number = 1 +) { + return new PointerEvent(type, { pointerId, clientX, clientY, button, buttons }); +} + +function createCircleSprite(engine: WebGLEngine, size: number): Sprite { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create canvas 2d context."); + } + + context.clearRect(0, 0, size, size); + context.fillStyle = "#ffffff"; + context.beginPath(); + context.arc(size * 0.5, size * 0.5, size * 0.46, 0, Math.PI * 2); + context.closePath(); + context.fill(); + + const texture = new Texture2D(engine, size, size); + texture.setImageSource(canvas); + return new Sprite(engine, texture); +} + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/tests/src/ui/RectMask2D.test.ts b/tests/src/ui/RectMask2D.test.ts new file mode 100644 index 0000000000..1b277d6e54 --- /dev/null +++ b/tests/src/ui/RectMask2D.test.ts @@ -0,0 +1,107 @@ +import { PointerEventData, Script, Sprite, Texture2D } from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { CanvasRenderMode, Image, RectMask2D, UICanvas, UITransform } from "@galacean/engine-ui"; +import { describe, expect, it } from "vitest"; + +describe("RectMask2D", async () => { + const body = document.getElementsByTagName("body")[0]; + const canvasDOM = document.createElement("canvas"); + canvasDOM.style.width = "18px"; + canvasDOM.style.height = "18px"; + body.appendChild(canvasDOM); + + const engine = await WebGLEngine.create({ canvas: canvasDOM }); + const webCanvas = engine.canvas; + webCanvas.width = 300; + webCanvas.height = 300; + const scene = engine.sceneManager.scenes[0]; + const root = scene.createRootEntity("root"); + const inputManager = engine.inputManager; + + const canvasEntity = root.createChild("canvas"); + const rootCanvas = canvasEntity.addComponent(UICanvas); + rootCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + rootCanvas.referenceResolutionPerUnit = 50; + rootCanvas.referenceResolution.set(300, 300); + + class ClickScript extends Script { + clickCount = 0; + + override onPointerClick(_eventData: PointerEventData): void { + ++this.clickCount; + } + } + + const rectMaskEntity = canvasEntity.createChild("rectMask"); + const rectMaskTransform = rectMaskEntity.transform; + rectMaskTransform.size.set(100, 100); + const rectMask = rectMaskEntity.addComponent(RectMask2D); + + const imageEntity = rectMaskEntity.createChild("image"); + const image = imageEntity.addComponent(Image); + (imageEntity.transform).size.set(300, 300); + image.sprite = new Sprite(engine, new Texture2D(engine, 1, 1)); + const clickScript = imageEntity.addComponent(ClickScript); + + let pointerId = 0; + const clickAtNormalizedPosition = (x: number, y: number): void => { + // @ts-ignore + const { _pointerManager: pointerManager } = inputManager; + const { _target: target } = pointerManager; + const rect = target.getBoundingClientRect(); + const clientX = rect.left + rect.width * x; + const clientY = rect.top + rect.height * y; + const id = ++pointerId; + target.dispatchEvent(generatePointerEvent("pointerdown", id, clientX, clientY)); + engine.update(); + target.dispatchEvent(generatePointerEvent("pointerup", id, clientX, clientY)); + engine.update(); + }; + + it("should only raycast within rect mask area", () => { + clickScript.clickCount = 0; + + clickAtNormalizedPosition(0.5, 0.5); + expect(clickScript.clickCount).toBe(1); + + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(1); + }); + + it("should update raycast result when rect mask size changes", () => { + clickScript.clickCount = 0; + + rectMaskTransform.size.set(100, 100); + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(0); + + rectMaskTransform.size.set(300, 300); + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(1); + }); + + it("should update raycast result when rect mask enabled state changes", () => { + rectMaskTransform.size.set(100, 100); + clickScript.clickCount = 0; + + rectMask.enabled = false; + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(1); + + rectMask.enabled = true; + clickScript.clickCount = 0; + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(0); + }); +}); + +function generatePointerEvent( + type: string, + pointerId: number, + clientX: number, + clientY: number, + button: number = 0, + buttons: number = 1 +) { + return new PointerEvent(type, { pointerId, clientX, clientY, button, buttons }); +} From 6b8b32817e8b9bf44113ed47cebb7ea75c58099c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 5 Mar 2026 15:39:34 +0800 Subject: [PATCH 03/10] feat: add mask --- packages/core/src/2d/sprite/MaskRenderable.ts | 364 ++++++++++++++++++ packages/core/src/2d/sprite/SpriteMask.ts | 296 ++------------ packages/core/src/2d/sprite/index.ts | 2 + packages/ui/src/component/advanced/Mask.ts | 288 +------------- 4 files changed, 415 insertions(+), 535 deletions(-) create mode 100644 packages/core/src/2d/sprite/MaskRenderable.ts diff --git a/packages/core/src/2d/sprite/MaskRenderable.ts b/packages/core/src/2d/sprite/MaskRenderable.ts new file mode 100644 index 0000000000..0a91be0860 --- /dev/null +++ b/packages/core/src/2d/sprite/MaskRenderable.ts @@ -0,0 +1,364 @@ +import { BoundingBox, Vector2, Vector3 } from "@galacean/engine-math"; +import { BatchUtils } from "../../RenderPipeline/BatchUtils"; +import { IMask } from "../../RenderPipeline/MaskManager"; +import { RenderElement } from "../../RenderPipeline/RenderElement"; +import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; +import { Renderer, RendererUpdateFlags } from "../../Renderer"; +import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { ShaderProperty } from "../../shader/ShaderProperty"; +import type { ISpriteRenderer } from "../assembler/ISpriteRenderer"; +import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; +import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; +import { Sprite } from "./Sprite"; +import { SpriteMaskUtils } from "./SpriteMaskUtils"; + +type RendererConstructor = abstract new (...args: any[]) => Renderer; + +/** @internal */ +const _maskTextureProperty = ShaderProperty.getByName("renderer_MaskTexture"); +/** @internal */ +const _alphaCutoffProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + +/** + * Public contract of the MaskRenderable mixin, used for declaration file generation. + */ +export interface IMaskRenderable extends IMask { + influenceLayers: SpriteMaskLayer; + flipX: boolean; + flipY: boolean; + sprite: Sprite; + alphaCutoff: number; + _renderElement: RenderElement; + _maskIndex: number; + _containsWorldPoint(worldPoint: Vector3): boolean; + _initMask(): void; + _cloneMaskData(target: IMaskRenderable): void; + _destroyMaskResources(): void; + _updateMaskBounds(worldBounds: BoundingBox): void; + _renderMask(distanceForSort: number): void; + _onSpriteChange(type: SpriteModifyFlags): void; + _onSpriteChangeExtra(type: SpriteModifyFlags): void; + _getMaskWidth(): number; + _getMaskHeight(): number; + _getMaskPivot(): Vector2; +} + +/** + * Mixin that provides shared mask rendering logic for both 2D SpriteMask and UI Mask. + */ +export function MaskRenderable( + Base: T +): (abstract new (...args: any[]) => IMaskRenderable) & T { + abstract class MaskRenderableBase extends Base implements IMask { + @assignmentClone + private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; + /** @internal */ + @ignoreClone + _renderElement: RenderElement; + /** @internal */ + @ignoreClone + _maskIndex: number = -1; + @ignoreClone + private _sprite: Sprite = null; + @assignmentClone + private _flipX: boolean = false; + @assignmentClone + private _flipY: boolean = false; + @assignmentClone + private _alphaCutoff: number = 0.5; + + /** + * The mask layers the sprite mask influence to. + */ + get influenceLayers(): SpriteMaskLayer { + return this._influenceLayers; + } + + set influenceLayers(value: SpriteMaskLayer) { + if (this._influenceLayers !== value) { + this._influenceLayers = value; + if (this._phasedActiveInScene) { + // @ts-ignore + this.scene._maskManager.onMaskInfluenceLayersChange(); + } + } + } + + /** + * Flips the sprite on the X axis. + */ + get flipX(): boolean { + return this._flipX; + } + + set flipX(value: boolean) { + if (this._flipX !== value) { + this._flipX = value; + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** + * Flips the sprite on the Y axis. + */ + get flipY(): boolean { + return this._flipY; + } + + set flipY(value: boolean) { + if (this._flipY !== value) { + this._flipY = value; + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** + * The Sprite to render. + */ + get sprite(): Sprite { + return this._sprite; + } + + set sprite(value: Sprite | null) { + this._sprite = SpriteMaskUtils.setSprite( + this, + this._sprite, + value, + this._onSpriteChange, + _maskTextureProperty, + MaskDirtyFlags.All + ); + } + + /** + * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. + */ + get alphaCutoff(): number { + return this._alphaCutoff; + } + + set alphaCutoff(value: number) { + if (this._alphaCutoff !== value) { + this._alphaCutoff = value; + this.shaderData.setFloat(_alphaCutoffProperty, value); + } + } + + /** + * @internal + */ + override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { + return BatchUtils.canBatchSpriteMask(elementA, elementB); + } + + /** + * @internal + */ + override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { + BatchUtils.batchFor2D(elementA, elementB); + } + + /** + * @internal + */ + // @ts-ignore + override _onEnableInScene(): void { + // @ts-ignore + super._onEnableInScene(); + // @ts-ignore + this.scene._maskManager.addSpriteMask(this); + } + + /** + * @internal + */ + // @ts-ignore + override _onDisableInScene(): void { + // @ts-ignore + super._onDisableInScene(); + // @ts-ignore + this.scene._maskManager.removeSpriteMask(this); + } + + /** + * @internal + */ + _containsWorldPoint(worldPoint: Vector3): boolean { + return SpriteMaskUtils.containsWorldPoint( + worldPoint, + this._sprite, + this._transformEntity.transform.worldMatrix, + this._getMaskWidth(), + this._getMaskHeight(), + this._getMaskPivot(), + this._flipX, + this._flipY, + this._alphaCutoff + ); + } + + /** + * @internal + * Initialize shared mask resources. Must be called from subclass constructor. + */ + _initMask(): void { + SimpleSpriteAssembler.resetData(this as unknown as ISpriteRenderer); + // @ts-ignore + this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); + this.shaderData.setFloat(_alphaCutoffProperty, this._alphaCutoff); + this._renderElement = new RenderElement(); + this._renderElement.addSubRenderElement(new SubRenderElement()); + this._onSpriteChange = this._onSpriteChange.bind(this); + } + + /** + * @internal + * Clone mask data to target. Called from subclass _cloneTo. + */ + _cloneMaskData(target: MaskRenderableBase): void { + target.sprite = this._sprite; + } + + /** + * @internal + * Release mask sprite resources. Called from subclass _onDestroy. + */ + _destroyMaskResources(): void { + SpriteMaskUtils.releaseSprite(this, this._sprite, this._onSpriteChange); + this._sprite = null; + this._renderElement = null; + } + + /** + * @internal + * Shared update-bounds logic via SpriteMaskUtils. + */ + _updateMaskBounds(worldBounds: BoundingBox): void { + const transform = this._transformEntity.transform; + SpriteMaskUtils.updateBounds( + this as unknown as ISpriteRenderer, + this._sprite, + worldBounds, + transform.worldMatrix, + transform.worldPosition, + this._getMaskWidth(), + this._getMaskHeight(), + this._getMaskPivot(), + this._flipX, + this._flipY + ); + } + + /** + * @internal + * Shared render logic for mask geometry. + */ + _renderMask(distanceForSort: number): void { + const { _sprite: sprite } = this; + const width = this._getMaskWidth(); + const height = this._getMaskHeight(); + if (!sprite?.texture || !width || !height) { + return; + } + + let material = this.getMaterial(); + if (!material) { + return; + } + // @ts-ignore + if (material.destroyed) { + // @ts-ignore + material = this._engine._basicResources.spriteMaskDefaultMaterial; + } + + // Update position + if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { + SpriteMaskUtils.updatePositions( + this as unknown as ISpriteRenderer, + this._transformEntity.transform.worldMatrix, + width, + height, + this._getMaskPivot(), + this._flipX, + this._flipY + ); + this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; + } + + // Update uv + if (this._dirtyUpdateFlag & MaskDirtyFlags.UV) { + SpriteMaskUtils.updateUVs(this as unknown as ISpriteRenderer); + this._dirtyUpdateFlag &= ~MaskDirtyFlags.UV; + } + + SpriteMaskUtils.setupRenderElement( + this._renderElement, + this, + material, + (this as any)._subChunk, + sprite.texture, + distanceForSort + ); + } + + /** @internal */ + @ignoreClone + _onSpriteChange(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.texture: + this.shaderData.setTexture(_maskTextureProperty, this.sprite.texture); + break; + case SpriteModifyFlags.region: + case SpriteModifyFlags.atlasRegionOffset: + this._dirtyUpdateFlag |= MaskDirtyFlags.WorldVolumeAndUV; + break; + case SpriteModifyFlags.atlasRegion: + this._dirtyUpdateFlag |= MaskDirtyFlags.UV; + break; + case SpriteModifyFlags.destroy: + this.sprite = null; + break; + default: + this._onSpriteChangeExtra(type); + break; + } + } + + /** + * @internal + * Hook for subclass-specific sprite change handling. + * SpriteMask overrides this to handle size/pivot changes. + */ + _onSpriteChangeExtra(type: SpriteModifyFlags): void {} + + /** @internal */ + _getMaskWidth(): number { + return 0; + } + /** @internal */ + _getMaskHeight(): number { + return 0; + } + /** @internal */ + _getMaskPivot(): Vector2 { + return null; + } + } + + return MaskRenderableBase as unknown as (abstract new (...args: any[]) => IMaskRenderable) & T; +} + +/** + * @remarks Extends `RendererUpdateFlags`. + */ +export enum MaskDirtyFlags { + /** UV. */ + UV = 0x2, + /** Automatic Size. */ + AutomaticSize = 0x4, + /** WorldVolume and UV. */ + WorldVolumeAndUV = 0x3, + /** All. */ + All = 0x7 +} diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index 693e72a067..e231fec058 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -1,46 +1,25 @@ -import { BoundingBox, Vector3 } from "@galacean/engine-math"; +import { BoundingBox } from "@galacean/engine-math"; import { Entity } from "../../Entity"; -import { BatchUtils } from "../../RenderPipeline/BatchUtils"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; -import { RenderElement } from "../../RenderPipeline/RenderElement"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; -import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { ShaderProperty } from "../../shader/ShaderProperty"; -import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; -import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; -import { Sprite } from "./Sprite"; -import { SpriteMaskUtils } from "./SpriteMaskUtils"; +import { MaskDirtyFlags, MaskRenderable } from "./MaskRenderable"; /** * A component for masking Sprites. */ -export class SpriteMask extends Renderer implements ISpriteRenderer { - /** @internal */ - static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); +export class SpriteMask extends MaskRenderable(Renderer) { /** @internal */ static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - - /** The mask layers the sprite mask influence to. */ - @assignmentClone - private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; /** @internal */ - @ignoreClone - _renderElement: RenderElement; - + static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); /** @internal */ @ignoreClone _subChunk: SubPrimitiveChunk; - /** @internal */ - @ignoreClone - _maskIndex: number = -1; - - @ignoreClone - private _sprite: Sprite = null; @ignoreClone private _automaticWidth: number = 0; @@ -50,29 +29,6 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { private _customWidth: number = undefined; @assignmentClone private _customHeight: number = undefined; - @assignmentClone - private _flipX: boolean = false; - @assignmentClone - private _flipY: boolean = false; - - @assignmentClone - private _alphaCutoff: number = 0.5; - - /** - * The mask layers the sprite mask influence to. - */ - get influenceLayers(): SpriteMaskLayer { - return this._influenceLayers; - } - - set influenceLayers(value: SpriteMaskLayer) { - if (this._influenceLayers !== value) { - this._influenceLayers = value; - if (this._phasedActiveInScene) { - this.scene._maskManager.onMaskInfluenceLayersChange(); - } - } - } /** * Render width (in world coordinates). @@ -85,7 +41,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { if (this._customWidth !== undefined) { return this._customWidth; } else { - this._dirtyUpdateFlag & SpriteMaskUpdateFlags.AutomaticSize && this._calDefaultSize(); + this._dirtyUpdateFlag & MaskDirtyFlags.AutomaticSize && this._calDefaultSize(); return this._automaticWidth; } } @@ -108,7 +64,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { if (this._customHeight !== undefined) { return this._customHeight; } else { - this._dirtyUpdateFlag & SpriteMaskUpdateFlags.AutomaticSize && this._calDefaultSize(); + this._dirtyUpdateFlag & MaskDirtyFlags.AutomaticSize && this._calDefaultSize(); return this._automaticHeight; } } @@ -120,77 +76,12 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { } } - /** - * Flips the sprite on the X axis. - */ - get flipX(): boolean { - return this._flipX; - } - - set flipX(value: boolean) { - if (this._flipX !== value) { - this._flipX = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * Flips the sprite on the Y axis. - */ - get flipY(): boolean { - return this._flipY; - } - - set flipY(value: boolean) { - if (this._flipY !== value) { - this._flipY = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * The Sprite to render. - */ - get sprite(): Sprite { - return this._sprite; - } - - set sprite(value: Sprite | null) { - this._sprite = SpriteMaskUtils.setSprite( - this, - this._sprite, - value, - this._onSpriteChange, - SpriteMask._textureProperty, - SpriteMaskUpdateFlags.All - ); - } - - /** - * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. - */ - get alphaCutoff(): number { - return this._alphaCutoff; - } - - set alphaCutoff(value: number) { - if (this._alphaCutoff !== value) { - this._alphaCutoff = value; - this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, value); - } - } - /** * @internal */ constructor(entity: Entity) { super(entity); - SimpleSpriteAssembler.resetData(this); - this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); - this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, this._alphaCutoff); - this._renderElement = new RenderElement(); - this._renderElement.addSubRenderElement(new SubRenderElement()); - this._onSpriteChange = this._onSpriteChange.bind(this); + this._initMask(); } /** @@ -206,37 +97,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { */ override _cloneTo(target: SpriteMask, srcRoot: Entity, targetRoot: Entity): void { super._cloneTo(target, srcRoot, targetRoot); - target.sprite = this._sprite; - } - - /** - * @internal - */ - override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { - return BatchUtils.canBatchSpriteMask(elementA, elementB); - } - - /** - * @internal - */ - override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { - BatchUtils.batchFor2D(elementA, elementB); - } - - /** - * @internal - */ - override _onEnableInScene(): void { - super._onEnableInScene(); - this.scene._maskManager.addSpriteMask(this); - } - - /** - * @internal - */ - override _onDisableInScene(): void { - super._onDisableInScene(); - this.scene._maskManager.removeSpriteMask(this); + this._cloneMaskData(target); } /** @@ -246,158 +107,65 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { return this.engine._batcherManager.primitiveChunkManagerMask; } - /** - * @internal - */ - _containsWorldPoint(worldPoint: Vector3): boolean { - const { _sprite: sprite } = this; - return SpriteMaskUtils.containsWorldPoint( - worldPoint, - sprite, - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite?.pivot, - this._flipX, - this._flipY, - this._alphaCutoff - ); - } - protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._sprite; - const transform = this._transformEntity.transform; - SpriteMaskUtils.updateBounds( - this, - sprite, - worldBounds, - transform.worldMatrix, - transform.worldPosition, - this.width, - this.height, - sprite?.pivot, - this._flipX, - this._flipY - ); + this._updateMaskBounds(worldBounds); } /** * @inheritdoc */ protected override _render(context: RenderContext): void { - const { _sprite: sprite } = this; - if (!sprite?.texture || !this.width || !this.height) { - return; - } - - let material = this.getMaterial(); - if (!material) { - return; - } - const { _engine: engine } = this; - // @todo: This question needs to be raised rather than hidden. - if (material.destroyed) { - material = engine._basicResources.spriteMaskDefaultMaterial; - } - - // Update position - if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - SpriteMaskUtils.updatePositions( - this, - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY - ); - this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; - } - - // Update uv - if (this._dirtyUpdateFlag & SpriteMaskUpdateFlags.UV) { - SpriteMaskUtils.updateUVs(this); - this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.UV; - } - SpriteMaskUtils.setupRenderElement( - this._renderElement, - this, - material, - this._subChunk, - this.sprite.texture, - this._distanceForSort - ); + this._renderMask(this._distanceForSort); } /** * @inheritdoc */ protected override _onDestroy(): void { - SpriteMaskUtils.releaseSprite(this, this._sprite, this._onSpriteChange); + this._destroyMaskResources(); super._onDestroy(); - this._sprite = null; if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); this._subChunk = null; } + } - this._renderElement = null; + override _getMaskWidth(): number { + return this.width; } - private _calDefaultSize(): void { - const sprite = this._sprite; - if (sprite) { - this._automaticWidth = sprite.width; - this._automaticHeight = sprite.height; - } else { - this._automaticWidth = this._automaticHeight = 0; - } - this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.AutomaticSize; + override _getMaskHeight(): number { + return this.height; } - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { + override _getMaskPivot() { + return this.sprite?.pivot; + } + + override _onSpriteChangeExtra(type: SpriteModifyFlags): void { switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(SpriteMask._textureProperty, this.sprite.texture); - break; case SpriteModifyFlags.size: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.AutomaticSize; + this._dirtyUpdateFlag |= MaskDirtyFlags.AutomaticSize; if (this._customWidth === undefined || this._customHeight === undefined) { this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } break; - case SpriteModifyFlags.region: - case SpriteModifyFlags.atlasRegionOffset: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.WorldVolumeAndUV; - break; - case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.UV; - break; case SpriteModifyFlags.pivot: this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; break; - case SpriteModifyFlags.destroy: - this.sprite = null; - break; - default: - break; } } -} -/** - * @remarks Extends `RendererUpdateFlags`. - */ -enum SpriteMaskUpdateFlags { - /** UV. */ - UV = 0x2, - /** Automatic Size. */ - AutomaticSize = 0x4, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** All. */ - All = 0x7 + private _calDefaultSize(): void { + const sprite = this.sprite; + if (sprite) { + this._automaticWidth = sprite.width; + this._automaticHeight = sprite.height; + } else { + this._automaticWidth = this._automaticHeight = 0; + } + this._dirtyUpdateFlag &= ~MaskDirtyFlags.AutomaticSize; + } } diff --git a/packages/core/src/2d/sprite/index.ts b/packages/core/src/2d/sprite/index.ts index a64ff00b87..c48fb9261b 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -1,3 +1,5 @@ +export type { IMaskRenderable } from "./MaskRenderable"; +export { MaskDirtyFlags, MaskRenderable } from "./MaskRenderable"; export { Sprite } from "./Sprite"; export { SpriteMask } from "./SpriteMask"; export { SpriteMaskUtils } from "./SpriteMaskUtils"; diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts index ef0ba98cd4..98b12e6e13 100644 --- a/packages/ui/src/component/advanced/Mask.ts +++ b/packages/ui/src/component/advanced/Mask.ts @@ -1,66 +1,8 @@ -import { - assignmentClone, - BatchUtils, - BoundingBox, - Entity, - ignoreClone, - ISpriteRenderer, - RenderElement, - RendererUpdateFlags, - ShaderProperty, - SimpleSpriteAssembler, - Sprite, - SpriteMaskLayer, - SpriteMaskUtils, - SpriteModifyFlags, - SubRenderElement, - Vector3 -} from "@galacean/engine"; +import { BoundingBox, Entity, MaskRenderable, Vector2 } from "@galacean/engine"; +import type { IMaskRenderable } from "@galacean/engine"; import { UIRenderer, UITransform } from ".."; -export class Mask extends UIRenderer implements ISpriteRenderer { - /** @internal */ - static _maskTextureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); - /** @internal */ - static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - - /** The mask layers the sprite mask influence to. */ - @assignmentClone - private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; - /** @internal */ - @ignoreClone - _renderElement; - /** @internal */ - @ignoreClone - _maskIndex: number = -1; - - @ignoreClone - private _sprite: Sprite = null; - @assignmentClone - private _flipX: boolean = false; - @assignmentClone - private _flipY: boolean = false; - - @assignmentClone - private _alphaCutoff: number = 0.5; - - /** - * The mask layers the sprite mask influence to. - */ - get influenceLayers(): SpriteMaskLayer { - return this._influenceLayers; - } - - set influenceLayers(value: SpriteMaskLayer) { - if (this._influenceLayers !== value) { - this._influenceLayers = value; - if (this._phasedActiveInScene) { - // @ts-ignore - this.scene._maskManager.onMaskInfluenceLayersChange(); - } - } - } - +export class Mask extends MaskRenderable(UIRenderer) { /** * @internal */ @@ -69,92 +11,12 @@ export class Mask extends UIRenderer implements ISpriteRenderer { return this.engine._batcherManager.primitiveChunkManagerMask; } - /** - * Flips the sprite on the X axis. - */ - get flipX(): boolean { - return this._flipX; - } - - set flipX(value: boolean) { - if (this._flipX !== value) { - this._flipX = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * Flips the sprite on the Y axis. - */ - get flipY(): boolean { - return this._flipY; - } - - set flipY(value: boolean) { - if (this._flipY !== value) { - this._flipY = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** - * The Sprite to render. - */ - get sprite(): Sprite { - return this._sprite; - } - - set sprite(value: Sprite | null) { - this._sprite = SpriteMaskUtils.setSprite( - this, - this._sprite, - value, - this._onSpriteChange, - Mask._maskTextureProperty, - MaskUpdateFlags.All - ); - } - - /** - * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. - */ - get alphaCutoff(): number { - return this._alphaCutoff; - } - - set alphaCutoff(value: number) { - if (this._alphaCutoff !== value) { - this._alphaCutoff = value; - this.shaderData.setFloat(Mask._alphaCutoffProperty, value); - } - } - - /** - * @internal - */ - override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { - return BatchUtils.canBatchSpriteMask(elementA, elementB); - } - - /** - * @internal - */ - override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { - BatchUtils.batchFor2D(elementA, elementB); - } - /** * @internal */ constructor(entity: Entity) { super(entity); - SimpleSpriteAssembler.resetData(this); - // @ts-ignore - this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); - this.shaderData.setFloat(Mask._alphaCutoffProperty, this._alphaCutoff); - this._renderElement = new RenderElement(); - this._renderElement.addSubRenderElement(new SubRenderElement()); - this._onSpriteChange = this._onSpriteChange.bind(this); + this._initMask(); this.raycastEnabled = false; } @@ -164,65 +26,13 @@ export class Mask extends UIRenderer implements ISpriteRenderer { _cloneTo(target: Mask, srcRoot: Entity, targetRoot: Entity): void { // @ts-ignore super._cloneTo(target, srcRoot, targetRoot); - target.sprite = this._sprite; - } - - /** - * @internal - */ - override _onEnableInScene(): void { - super._onEnableInScene(); - // @ts-ignore - this.scene._maskManager.addSpriteMask(this); - } - - /** - * @internal - */ - override _onDisableInScene(): void { - super._onDisableInScene(); - // @ts-ignore - this.scene._maskManager.removeSpriteMask(this); - } - - /** - * @internal - */ - _containsWorldPoint(worldPoint: Vector3): boolean { - const { _sprite: sprite } = this; - const transform = this._transformEntity.transform; - const { x: width, y: height } = transform.size; - return SpriteMaskUtils.containsWorldPoint( - worldPoint, - sprite, - transform.worldMatrix, - width, - height, - transform.pivot, - this._flipX, - this._flipY, - this._alphaCutoff - ); + this._cloneMaskData(target); } protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._sprite; const rootCanvas = this._getRootCanvas(); - if (sprite && rootCanvas) { - const transform = this._transformEntity.transform; - const { x: width, y: height } = transform.size; - SpriteMaskUtils.updateBounds( - this, - sprite, - worldBounds, - transform.worldMatrix, - transform.worldPosition, - width, - height, - transform.pivot, - this._flipX, - this._flipY - ); + if (this.sprite && rootCanvas) { + this._updateMaskBounds(worldBounds); } else { const { worldPosition } = this._transformEntity.transform; worldBounds.min.copyFrom(worldPosition); @@ -234,53 +44,14 @@ export class Mask extends UIRenderer implements ISpriteRenderer { * @inheritdoc */ protected override _render(context): void { - const { _sprite: sprite } = this; - const transform = this._transformEntity.transform; - const { x: width, y: height } = transform.size; - if (!sprite?.texture || !width || !height) { - return; - } - - let material = this.getMaterial(); - if (!material) { - return; - } - // @ts-ignore - const { _engine: engine } = this; - // @todo: This question needs to be raised rather than hidden. - if (material.destroyed) { - // @ts-ignore - material = engine._basicResources.spriteMaskDefaultMaterial; - } - - // Update position - if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - SpriteMaskUtils.updatePositions( - this, - transform.worldMatrix, - width, - height, - transform.pivot, - this._flipX, - this._flipY - ); - this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; - } - - // Update uv - if (this._dirtyUpdateFlag & MaskUpdateFlags.UV) { - SpriteMaskUtils.updateUVs(this); - this._dirtyUpdateFlag &= ~MaskUpdateFlags.UV; - } - SpriteMaskUtils.setupRenderElement(this._renderElement, this, material, this._subChunk, this.sprite.texture, 0); + this._renderMask(0); } /** * @inheritdoc */ protected override _onDestroy(): void { - SpriteMaskUtils.releaseSprite(this, this._sprite, this._onSpriteChange); - this._sprite = null; + this._destroyMaskResources(); super._onDestroy(); @@ -288,42 +59,17 @@ export class Mask extends UIRenderer implements ISpriteRenderer { this._getChunkManager().freeSubChunk(this._subChunk); this._subChunk = null; } + } - this._renderElement = null; + override _getMaskWidth(): number { + return (this._transformEntity.transform).size.x; } - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { - switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(Mask._maskTextureProperty, this.sprite.texture); - break; - case SpriteModifyFlags.region: - case SpriteModifyFlags.atlasRegionOffset: - this._dirtyUpdateFlag |= MaskUpdateFlags.WorldVolumeAndUV; - break; - case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= MaskUpdateFlags.UV; - break; - case SpriteModifyFlags.destroy: - this.sprite = null; - break; - default: - break; - } + override _getMaskHeight(): number { + return (this._transformEntity.transform).size.y; } -} -/** - * @remarks Extends `RendererUpdateFlags`. - */ -enum MaskUpdateFlags { - /** UV. */ - UV = 0x2, - /** Automatic Size. */ - AutomaticSize = 0x4, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** All. */ - All = 0x7 + override _getMaskPivot(): Vector2 { + return (this._transformEntity.transform).pivot; + } } From f4a23c332b4a8c79a0ebad93542b396ccb276700 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 5 Mar 2026 16:36:43 +0800 Subject: [PATCH 04/10] feat: update comments --- packages/ui/src/Utils.ts | 10 ++-- .../ui/src/component/advanced/ScrollView.ts | 54 +++++++++---------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index 69ca34547b..5e54dc5371 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -157,11 +157,11 @@ export class Utils { static _tempVec3: Vector3 = new Vector3(); static _tempMat: Matrix = new Matrix(); /** - * 屏幕上的点在组件中的局部位置 + * Local position of a screen point in the component */ static screenToLocalPoint(position: Vector2, transform: UITransform, out: Vector3): Boolean { const engine = transform.engine; - // 获取主画布 + // Get root canvas let entity = transform.entity; let rootCanvas: UICanvas; while (entity) { @@ -176,7 +176,7 @@ export class Utils { entity = entity.parent; } if (!rootCanvas) return false; - // 计算 Ray + // Calculate ray const ray = this._tempRay; switch (rootCanvas._realRenderMode) { case CanvasRenderMode.ScreenSpaceOverlay: @@ -188,10 +188,10 @@ export class Utils { rootCanvas.renderCamera.screenPointToRay(position, ray); break; default: - // 暂不支持世界空间,详见 issue #2793 + // World space not yet supported, see issue #2793 return false; } - // 用这个射线和 UI 平面相交获取局部坐标 + // Intersect ray with UI plane to get local coordinates const plane = this._tempPlane; const normal = plane.normal.copyFrom(transform.worldForward); plane.distance = -Vector3.dot(normal, transform.worldPosition); diff --git a/packages/ui/src/component/advanced/ScrollView.ts b/packages/ui/src/component/advanced/ScrollView.ts index 80c8b028ec..80844479e8 100644 --- a/packages/ui/src/component/advanced/ScrollView.ts +++ b/packages/ui/src/component/advanced/ScrollView.ts @@ -6,38 +6,38 @@ import { UITransform } from "../UITransform"; * Vertical scroll view */ export class ScrollView extends Script { - // 视口 + // Viewport private _viewportTransform: UITransform; - // 内容 + // Content private _contentTransform: UITransform; - // 类型 + // Type private _mode: ScrollViewMode = ScrollViewMode.VerticalAndHorizontal; - // 滑动类型 + // Scroll type private _movementType: MovementType = MovementType.Clamped; - // 惯性 + // Inertia private _inertia: boolean = true; - // 当前滑动速度 + // Current scroll velocity private _velocity: Vector2 = new Vector2(); - // 惯性速度阈值 + // Inertia velocity threshold private _threshold = 10; - // 惯性衰减系数 + // Inertia deceleration factor private _decelerationRate: number = 0.05; - // 计算惯性时把每帧分成多个 1 / 240 秒时间片,模拟微积分 + // Split each frame into multiple 1/240s time slices for inertia calculation, simulating integration private _inertiaBaseTimeInterval = 1 / 240; - // 正在拖拽 + // Is dragging private _isDragging: boolean = false; - // 计算边界 + // Calculate bounds private _viewportRect: Rect = new Rect(); private _contentRect: Rect = new Rect(); @ignoreClone private _listeners: SafeLoopArray = new SafeLoopArray(); - // 拖动行为的起始点(以 viewport 的局部坐标系为参考坐标系) + // Drag start point (in viewport local coordinates) private _startPoint: Vector3 = new Vector3(); - // **本帧**移动的起点(以 viewport 的局部坐标系为参考坐标系) + // Move start point for current frame (in viewport local coordinates) private _fromPoint: Vector3 = new Vector3(); - // **本帧**移动的终点(以 viewport 的局部坐标系为参考坐标系) + // Move end point for current frame (in viewport local coordinates) private _toPoint: Vector3 = new Vector3(); get mode(): ScrollViewMode { @@ -65,7 +65,7 @@ export class ScrollView extends Script { } /** - * 添加滑动回调 + * Add scroll callback * @param listener - The listening function */ addOnScroll(listener: (percentH: number, percentV: number) => void): void { @@ -73,7 +73,7 @@ export class ScrollView extends Script { } /** - * 移除滑动回调 + * Remove scroll callback * @param listener - The listening function */ removeOnScroll(listener: (val: number) => void): void { @@ -84,7 +84,7 @@ export class ScrollView extends Script { this._isDragging = true; const { _viewportTransform: viewportTransform } = this; if (!viewportTransform || !this._contentTransform) return; - // 初始化拖动的起始点 + // Initialize drag start point Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, this._startPoint); this._fromPoint.copyFrom(this._startPoint); } @@ -93,22 +93,22 @@ export class ScrollView extends Script { const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; if (!viewportTransform || !contentTransform) return; const { _fromPoint: fromPoint, _toPoint: toPoint } = this; - // 更新 toPoint + // Update toPoint Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, toPoint); - // 从 fromPoint 到 toPoint + // From fromPoint to toPoint this._updateRect(); this._computeScrolling(fromPoint, toPoint); - // 更新 fromPoint + // Update fromPoint fromPoint.copyFrom(toPoint) } private _computeScrolling(from: Vector3, to: Vector3) { const { _contentTransform: contentTransform, _mode: mode } = this; const contentScale = contentTransform.scale; - // 根据 mode 计算 delta + // Calculate delta based on mode let deltaX = (mode & ScrollViewMode.Horizontal) ? (to.x - from.x) * contentScale.x : 0; let deltaY = (mode & ScrollViewMode.Vertical) ? (to.y - from.y) * contentScale.y : 0; - // 根据滑动类型计算滑块的运动 + // Calculate scroll movement based on scroll type switch (this._movementType) { case MovementType.Clamped: const { _viewportRect: viewportRect, _contentRect: contentRect } = this; @@ -136,13 +136,13 @@ export class ScrollView extends Script { } break; case MovementType.Elastic: - // 暂未实现 + // Not yet implemented break; default: break; } this._applyScrolling(deltaX, deltaY); - // 记录此时的速度,可以计算惯性 + // Record current velocity for inertia calculation const delta = this.engine.time.actualDeltaTime; const invDeltaTime = 1 / delta; const velocity = this._velocity; @@ -153,7 +153,7 @@ export class ScrollView extends Script { private _applyScrolling(dx: number, dy: number): void { const contentPosition = this._contentTransform.position; const { _contentRect: contentRect, _viewportRect: viewportRect } = this; - // 计算是否碰到边缘 + // Check if reached edge contentPosition.set(contentPosition.x + dx, contentPosition.y + dy, contentPosition.z); const percentH = (viewportRect.x - contentRect.x) / (contentRect.width - viewportRect.width); const percentV = (contentRect.y + contentRect.height - viewportRect.y - viewportRect.height) / (contentRect.height - viewportRect.height) @@ -219,11 +219,11 @@ export class ScrollView extends Script { const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; if (viewportTransform && contentTransform) { const { _viewportRect: viewportRect, _contentRect: contentRect } = this; - // viewport 的 localRect + // Viewport local rect const viewportPivot = viewportTransform.pivot; const viewportSize = viewportTransform.size; viewportRect.set(-viewportPivot.x * viewportSize.x, -viewportPivot.y * viewportSize.y, viewportSize.x, viewportSize.y); - // content 相对于 viewport 的 Rect + // Content rect relative to viewport const contentPosition = contentTransform.position; const contentScale = contentTransform.scale; const contentPivot = contentTransform.pivot; From ececde4bd3200deea0a96047fd615068d5a5e86c Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 6 Mar 2026 15:33:33 +0800 Subject: [PATCH 05/10] fix: correct ui rect mask clip coordinates --- packages/core/src/shaderlib/extra/text.vs.glsl | 3 +-- packages/ui/src/shader/uiDefault.vs.glsl | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/shaderlib/extra/text.vs.glsl b/packages/core/src/shaderlib/extra/text.vs.glsl index b690be7fa3..c3971d0172 100644 --- a/packages/core/src/shaderlib/extra/text.vs.glsl +++ b/packages/core/src/shaderlib/extra/text.vs.glsl @@ -11,10 +11,9 @@ varying vec2 v_worldPosition; void main() { - vec4 worldPosition = renderer_ModelMat * vec4(POSITION, 1.0); gl_Position = renderer_MVPMat * vec4(POSITION, 1.0); v_uv = TEXCOORD_0; v_color = COLOR_0; - v_worldPosition = worldPosition.xy; + v_worldPosition = POSITION.xy; } diff --git a/packages/ui/src/shader/uiDefault.vs.glsl b/packages/ui/src/shader/uiDefault.vs.glsl index 35c78a9cf2..52345d9abf 100644 --- a/packages/ui/src/shader/uiDefault.vs.glsl +++ b/packages/ui/src/shader/uiDefault.vs.glsl @@ -10,10 +10,9 @@ varying vec4 v_color; varying vec2 v_worldPosition; void main() { - vec4 worldPosition = renderer_ModelMat * vec4(POSITION, 1.0); gl_Position = renderer_MVPMat * vec4(POSITION, 1.0); v_uv = TEXCOORD_0; v_color = COLOR_0; - v_worldPosition = worldPosition.xy; + v_worldPosition = POSITION.xy; } From 03ee15ec34d3c8f5e2cb8b2bb77c271e8c913a3d Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 6 Mar 2026 15:40:45 +0800 Subject: [PATCH 06/10] feat: add rect mask --- examples/src/ui-scrollview.ts | 164 ++++++++++++++++++ packages/core/src/2d/sprite/MaskRenderable.ts | 5 +- .../core/src/RenderPipeline/MaskManager.ts | 23 +-- packages/ui/src/component/index.ts | 2 +- 4 files changed, 173 insertions(+), 21 deletions(-) create mode 100644 examples/src/ui-scrollview.ts diff --git a/examples/src/ui-scrollview.ts b/examples/src/ui-scrollview.ts new file mode 100644 index 0000000000..1481d901f3 --- /dev/null +++ b/examples/src/ui-scrollview.ts @@ -0,0 +1,164 @@ +/** + * @title UI ScrollView + * @category UI + */ +import { + Camera, + Color, + Entity, + Script, + Sprite, + Texture2D, + TextureFormat, + Vector4, + WebGLEngine +} from "@galacean/engine"; +import { + CanvasRenderMode, + Image, + RectMask2D, + ScrollView, + ScrollViewMode, + Text, + UICanvas, + UITransform +} from "@galacean/engine-ui"; + +async function main() { + const engine = await WebGLEngine.create({ canvas: "canvas" }); + engine.canvas.resizeByClientSize(); + window.addEventListener("resize", () => engine.canvas.resizeByClientSize()); + + const scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + uiCanvas.referenceResolutionPerUnit = 100; + uiCanvas.referenceResolution.set(800, 600); + + const solidSprite = createSolidSprite(engine); + + // --- Viewport --- + const viewportEntity = canvasEntity.createChild("Viewport"); + const viewportTransform = viewportEntity.transform as UITransform; + viewportTransform.size.set(400, 300); + + const viewportBg = viewportEntity.addComponent(Image); + viewportBg.sprite = solidSprite; + viewportBg.color.set(0.15, 0.15, 0.15, 1); + viewportBg.raycastEnabled = true; + + const rectMask = viewportEntity.addComponent(RectMask2D); + rectMask.alphaClip = true; + + // --- Content (child of viewport) --- + const cols = 4; + const rows = 3; + const tileW = 180; + const tileH = 180; + const gap = 10; + const contentW = cols * (tileW + gap) - gap; + const contentH = rows * (tileH + gap) - gap; + + const contentEntity = viewportEntity.createChild("Content"); + const contentTransform = contentEntity.transform as UITransform; + contentTransform.size.set(contentW, contentH); + + // --- ScrollView script on viewport --- + const scrollView = viewportEntity.addComponent(ScrollView); + scrollView.mode = ScrollViewMode.VerticalAndHorizontal; + scrollView.viewport = viewportEntity; + scrollView.content = contentEntity; + + // --- Tiles --- + const colors = [ + new Color(0.91, 0.3, 0.24, 1), + new Color(0.16, 0.5, 0.73, 1), + new Color(0.18, 0.8, 0.44, 1), + new Color(0.95, 0.61, 0.07, 1), + new Color(0.56, 0.27, 0.68, 1), + new Color(0.2, 0.6, 0.86, 1), + new Color(0.83, 0.33, 0.33, 1), + new Color(0.1, 0.74, 0.61, 1), + new Color(0.93, 0.78, 0.0, 1), + new Color(0.5, 0.55, 0.53, 1), + new Color(0.61, 0.35, 0.71, 1), + new Color(0.2, 0.29, 0.37, 1) + ]; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const idx = r * cols + c; + const tileEntity = contentEntity.createChild(`Tile_${idx}`); + const tileTransform = tileEntity.transform as UITransform; + tileTransform.size.set(tileW, tileH); + + // Layout from top-left of content + const x = c * (tileW + gap) - contentW / 2 + tileW / 2; + const y = contentH / 2 - tileH / 2 - r * (tileH + gap); + tileTransform.setPosition(x, y, 0); + + const tileImage = tileEntity.addComponent(Image); + tileImage.sprite = solidSprite; + tileImage.color = colors[idx % colors.length]; + tileImage.raycastEnabled = false; + + const labelEntity = tileEntity.createChild("Label"); + (labelEntity.transform as UITransform).size.set(tileW, tileH); + const label = labelEntity.addComponent(Text); + label.text = `${idx + 1}`; + label.fontSize = 36; + label.color = new Color(1, 1, 1, 1); + label.raycastEnabled = false; + } + } + + // --- Title --- + const titleEntity = canvasEntity.createChild("Title"); + const titleTransform = titleEntity.transform as UITransform; + titleTransform.size.set(400, 50); + titleTransform.setPosition(0, 210, 0); + const title = titleEntity.addComponent(Text); + title.text = "Drag to scroll"; + title.fontSize = 28; + title.color = new Color(1, 1, 1, 1); + title.raycastEnabled = false; + + // Debug: log clip rect and tile world positions + class DebugScript extends Script { + private _logged = false; + override onUpdate(): void { + if (this._logged) return; + this._logged = true; + const debugRect = new Vector4(); + // @ts-ignore + const hasRect = rectMask._getWorldRect(debugRect); + console.log("RectMask2D worldRect:", hasRect, debugRect.x, debugRect.y, debugRect.z, debugRect.w); + const firstTile = contentEntity.children[0]; + if (firstTile) { + const wp = firstTile.transform.worldPosition; + console.log("Tile0 worldPosition:", wp.x, wp.y, wp.z); + } + const vp = viewportEntity.transform.worldPosition; + console.log("Viewport worldPosition:", vp.x, vp.y, vp.z); + console.log("Viewport worldMatrix:", viewportTransform.worldMatrix.elements); + } + } + viewportEntity.addComponent(DebugScript); + + engine.run(); +} + +main(); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1, TextureFormat.R8G8B8A8, false); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/packages/core/src/2d/sprite/MaskRenderable.ts b/packages/core/src/2d/sprite/MaskRenderable.ts index 0a91be0860..254e8f3d0a 100644 --- a/packages/core/src/2d/sprite/MaskRenderable.ts +++ b/packages/core/src/2d/sprite/MaskRenderable.ts @@ -1,6 +1,5 @@ import { BoundingBox, Vector2, Vector3 } from "@galacean/engine-math"; import { BatchUtils } from "../../RenderPipeline/BatchUtils"; -import { IMask } from "../../RenderPipeline/MaskManager"; import { RenderElement } from "../../RenderPipeline/RenderElement"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; @@ -23,7 +22,7 @@ const _alphaCutoffProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff" /** * Public contract of the MaskRenderable mixin, used for declaration file generation. */ -export interface IMaskRenderable extends IMask { +export interface IMaskRenderable { influenceLayers: SpriteMaskLayer; flipX: boolean; flipY: boolean; @@ -50,7 +49,7 @@ export interface IMaskRenderable extends IMask { export function MaskRenderable( Base: T ): (abstract new (...args: any[]) => IMaskRenderable) & T { - abstract class MaskRenderableBase extends Base implements IMask { + abstract class MaskRenderableBase extends Base implements IMaskRenderable { @assignmentClone private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; /** @internal */ diff --git a/packages/core/src/RenderPipeline/MaskManager.ts b/packages/core/src/RenderPipeline/MaskManager.ts index 98c962b683..8663277a96 100644 --- a/packages/core/src/RenderPipeline/MaskManager.ts +++ b/packages/core/src/RenderPipeline/MaskManager.ts @@ -1,4 +1,5 @@ import { Vector3 } from "@galacean/engine-math"; +import { IMaskRenderable } from "../2d/sprite/MaskRenderable"; import { SpriteMaskInteraction } from "../2d/enums/SpriteMaskInteraction"; import { CameraClearFlags } from "../enums/CameraClearFlags"; import { SpriteMaskLayer } from "../enums/SpriteMaskLayer"; @@ -8,21 +9,9 @@ import { RenderQueueType } from "../shader/enums/RenderQueueType"; import { StencilOperation } from "../shader/enums/StencilOperation"; import { DisorderedArray } from "../utils/DisorderedArray"; import { RenderContext } from "./RenderContext"; -import { RenderElement } from "./RenderElement"; import { RenderQueue } from "./RenderQueue"; import { RenderQueueMaskType } from "./enums/RenderQueueMaskType"; -/** - * @internal - */ -export interface IMask { - influenceLayers: SpriteMaskLayer; - _renderElement: RenderElement; - _maskIndex: number; - - _containsWorldPoint(worldPoint: Vector3): boolean; -} - /** * @internal */ @@ -41,17 +30,17 @@ export class MaskManager { hasStencilWritten = false; private _preMaskLayer = SpriteMaskLayer.Nothing; - private _allSpriteMasks = new DisorderedArray(); - private _filteredMasksByLayer = new Map(); + private _allSpriteMasks = new DisorderedArray(); + private _filteredMasksByLayer = new Map(); private _isFilteredMasksDirty = true; - addSpriteMask(mask: IMask): void { + addSpriteMask(mask: IMaskRenderable): void { mask._maskIndex = this._allSpriteMasks.length; this._allSpriteMasks.add(mask); this._setFilteredMasksDirty(); } - removeSpriteMask(mask: IMask): void { + removeSpriteMask(mask: IMaskRenderable): void { const replaced = this._allSpriteMasks.deleteByIndex(mask._maskIndex); replaced && (replaced._maskIndex = mask._maskIndex); mask._maskIndex = -1; @@ -171,7 +160,7 @@ export class MaskManager { this._isFilteredMasksDirty = true; } - private _getMasksByLayer(maskLayer: SpriteMaskLayer): IMask[] { + private _getMasksByLayer(maskLayer: SpriteMaskLayer): IMaskRenderable[] { if (maskLayer === SpriteMaskLayer.Nothing) { return []; } diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts index 7eaa6ab248..261e2a3fe5 100644 --- a/packages/ui/src/component/index.ts +++ b/packages/ui/src/component/index.ts @@ -2,7 +2,7 @@ export { Button } from "./advanced/Button"; export { Image } from "./advanced/Image"; export { Mask } from "./advanced/Mask"; export { RectMask2D } from "./advanced/RectMask2D"; -export { ScrollView } from "./advanced/ScrollView"; +export { MovementType, ScrollView, ScrollViewMode } from "./advanced/ScrollView"; export { Text } from "./advanced/Text"; export { ColorTransition } from "./interactive/transition/ColorTransition"; export { ScaleTransition } from "./interactive/transition/ScaleTransition"; From 1922888b42fca8b19f2c0e149c4ba2818ec478bc Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Fri, 6 Mar 2026 15:47:05 +0800 Subject: [PATCH 07/10] fix: lint --- packages/core/src/RenderPipeline/index.ts | 1 - packages/ui/src/component/UIRenderer.ts | 14 +- .../ui/src/component/advanced/ScrollView.ts | 440 +++++++++--------- 3 files changed, 235 insertions(+), 220 deletions(-) diff --git a/packages/core/src/RenderPipeline/index.ts b/packages/core/src/RenderPipeline/index.ts index af4130800f..d4e920744e 100644 --- a/packages/core/src/RenderPipeline/index.ts +++ b/packages/core/src/RenderPipeline/index.ts @@ -5,4 +5,3 @@ export { PipelineStage } from "./enums/PipelineStage"; export { RenderElement } from "./RenderElement"; export { RenderQueue } from "./RenderQueue"; export { SubRenderElement } from "./SubRenderElement"; - diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index f4216843fa..6195d97224 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -383,7 +383,12 @@ export class UIRenderer extends Renderer implements IGraphics { this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); } const rectMaskSoftness = this._rectMaskSoftness; - if (rectMaskSoftness.x !== 0 || rectMaskSoftness.y !== 0 || rectMaskSoftness.z !== 0 || rectMaskSoftness.w !== 0) { + 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); } @@ -446,7 +451,12 @@ export class UIRenderer extends Renderer implements IGraphics { this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); } const rectMaskSoftness = this._rectMaskSoftness; - if (rectMaskSoftness.x !== 0 || rectMaskSoftness.y !== 0 || rectMaskSoftness.z !== 0 || rectMaskSoftness.w !== 0) { + 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); } diff --git a/packages/ui/src/component/advanced/ScrollView.ts b/packages/ui/src/component/advanced/ScrollView.ts index 80844479e8..4f731ad48a 100644 --- a/packages/ui/src/component/advanced/ScrollView.ts +++ b/packages/ui/src/component/advanced/ScrollView.ts @@ -6,256 +6,262 @@ import { UITransform } from "../UITransform"; * Vertical scroll view */ export class ScrollView extends Script { - // Viewport - private _viewportTransform: UITransform; - // Content - private _contentTransform: UITransform; - // Type - private _mode: ScrollViewMode = ScrollViewMode.VerticalAndHorizontal; - // Scroll type - private _movementType: MovementType = MovementType.Clamped; - // Inertia - private _inertia: boolean = true; - // Current scroll velocity - private _velocity: Vector2 = new Vector2(); - // Inertia velocity threshold - private _threshold = 10; - // Inertia deceleration factor - private _decelerationRate: number = 0.05; - // Split each frame into multiple 1/240s time slices for inertia calculation, simulating integration - private _inertiaBaseTimeInterval = 1 / 240; + // Viewport + private _viewportTransform: UITransform; + // Content + private _contentTransform: UITransform; + // Type + private _mode: ScrollViewMode = ScrollViewMode.VerticalAndHorizontal; + // Scroll type + private _movementType: MovementType = MovementType.Clamped; + // Inertia + private _inertia: boolean = true; + // Current scroll velocity + private _velocity: Vector2 = new Vector2(); + // Inertia velocity threshold + private _threshold = 10; + // Inertia deceleration factor + private _decelerationRate: number = 0.05; + // Split each frame into multiple 1/240s time slices for inertia calculation, simulating integration + private _inertiaBaseTimeInterval = 1 / 240; - // Is dragging - private _isDragging: boolean = false; - // Calculate bounds - private _viewportRect: Rect = new Rect(); - private _contentRect: Rect = new Rect(); - @ignoreClone - private _listeners: SafeLoopArray = new SafeLoopArray(); + // Is dragging + private _isDragging: boolean = false; + // Calculate bounds + private _viewportRect: Rect = new Rect(); + private _contentRect: Rect = new Rect(); + @ignoreClone + private _listeners: SafeLoopArray = new SafeLoopArray(); - // Drag start point (in viewport local coordinates) - private _startPoint: Vector3 = new Vector3(); - // Move start point for current frame (in viewport local coordinates) - private _fromPoint: Vector3 = new Vector3(); - // Move end point for current frame (in viewport local coordinates) - private _toPoint: Vector3 = new Vector3(); + // Drag start point (in viewport local coordinates) + private _startPoint: Vector3 = new Vector3(); + // Move start point for current frame (in viewport local coordinates) + private _fromPoint: Vector3 = new Vector3(); + // Move end point for current frame (in viewport local coordinates) + private _toPoint: Vector3 = new Vector3(); - get mode(): ScrollViewMode { - return this._mode; - } + get mode(): ScrollViewMode { + return this._mode; + } - set mode(value: number) { - this._mode = value; - } + set mode(value: number) { + this._mode = value; + } - get viewport() { - return this._viewportTransform?.entity; - } + get viewport() { + return this._viewportTransform?.entity; + } - set viewport(entity: Entity) { - this._viewportTransform = entity.transform as UITransform; - } + set viewport(entity: Entity) { + this._viewportTransform = entity.transform as UITransform; + } - get content(): Entity { - return this._contentTransform?.entity;; - } + get content(): Entity { + return this._contentTransform?.entity; + } - set content(entity: Entity) { - this._contentTransform = entity.transform as UITransform; - } + set content(entity: Entity) { + this._contentTransform = entity.transform as UITransform; + } - /** - * Add scroll callback - * @param listener - The listening function - */ - addOnScroll(listener: (percentH: number, percentV: number) => void): void { - this._listeners.push({ fn: listener }); - } + /** + * Add scroll callback + * @param listener - The listening function + */ + addOnScroll(listener: (percentH: number, percentV: number) => void): void { + this._listeners.push({ fn: listener }); + } - /** - * Remove scroll callback - * @param listener - The listening function - */ - removeOnScroll(listener: (val: number) => void): void { - this._listeners.findAndRemove((value) => (value.fn === listener ? (value.destroyed = true) : false)); - } + /** + * Remove scroll callback + * @param listener - The listening function + */ + removeOnScroll(listener: (val: number) => void): void { + this._listeners.findAndRemove((value) => (value.fn === listener ? (value.destroyed = true) : false)); + } - override onPointerBeginDrag(eventData: PointerEventData): void { - this._isDragging = true; - const { _viewportTransform: viewportTransform } = this; - if (!viewportTransform || !this._contentTransform) return; - // Initialize drag start point - Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, this._startPoint); - this._fromPoint.copyFrom(this._startPoint); - } + override onPointerBeginDrag(eventData: PointerEventData): void { + this._isDragging = true; + const { _viewportTransform: viewportTransform } = this; + if (!viewportTransform || !this._contentTransform) return; + // Initialize drag start point + Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, this._startPoint); + this._fromPoint.copyFrom(this._startPoint); + } - override onPointerDrag(eventData: PointerEventData): void { - const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; - if (!viewportTransform || !contentTransform) return; - const { _fromPoint: fromPoint, _toPoint: toPoint } = this; - // Update toPoint - Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, toPoint); - // From fromPoint to toPoint - this._updateRect(); - this._computeScrolling(fromPoint, toPoint); - // Update fromPoint - fromPoint.copyFrom(toPoint) - } + override onPointerDrag(eventData: PointerEventData): void { + const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; + if (!viewportTransform || !contentTransform) return; + const { _fromPoint: fromPoint, _toPoint: toPoint } = this; + // Update toPoint + Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, toPoint); + // From fromPoint to toPoint + this._updateRect(); + this._computeScrolling(fromPoint, toPoint); + // Update fromPoint + fromPoint.copyFrom(toPoint); + } - private _computeScrolling(from: Vector3, to: Vector3) { - const { _contentTransform: contentTransform, _mode: mode } = this; - const contentScale = contentTransform.scale; - // Calculate delta based on mode - let deltaX = (mode & ScrollViewMode.Horizontal) ? (to.x - from.x) * contentScale.x : 0; - let deltaY = (mode & ScrollViewMode.Vertical) ? (to.y - from.y) * contentScale.y : 0; - // Calculate scroll movement based on scroll type - switch (this._movementType) { - case MovementType.Clamped: - const { _viewportRect: viewportRect, _contentRect: contentRect } = this; - if (mode & ScrollViewMode.Horizontal) { - if (deltaX > 0) { - if (contentRect.x + deltaX > viewportRect.x) { - deltaX = viewportRect.x - contentRect.x; - } - } else if (deltaX < 0) { - if (contentRect.x + contentRect.width + deltaX < viewportRect.x + viewportRect.width) { - deltaX = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; - } - } - } - if (mode & ScrollViewMode.Vertical) { - if (deltaY > 0) { - if (contentRect.y + deltaY > viewportRect.y) { - deltaY = viewportRect.y - contentRect.y; - } - } else if (deltaY < 0) { - if (contentRect.y + contentRect.height + deltaY < viewportRect.y + viewportRect.height) { - deltaY = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; - } - } - } - break; - case MovementType.Elastic: - // Not yet implemented - break; - default: - break; + private _computeScrolling(from: Vector3, to: Vector3) { + const { _contentTransform: contentTransform, _mode: mode } = this; + const contentScale = contentTransform.scale; + // Calculate delta based on mode + let deltaX = mode & ScrollViewMode.Horizontal ? (to.x - from.x) * contentScale.x : 0; + let deltaY = mode & ScrollViewMode.Vertical ? (to.y - from.y) * contentScale.y : 0; + // Calculate scroll movement based on scroll type + switch (this._movementType) { + case MovementType.Clamped: + const { _viewportRect: viewportRect, _contentRect: contentRect } = this; + if (mode & ScrollViewMode.Horizontal) { + if (deltaX > 0) { + if (contentRect.x + deltaX > viewportRect.x) { + deltaX = viewportRect.x - contentRect.x; + } + } else if (deltaX < 0) { + if (contentRect.x + contentRect.width + deltaX < viewportRect.x + viewportRect.width) { + deltaX = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; + } + } } - this._applyScrolling(deltaX, deltaY); - // Record current velocity for inertia calculation - const delta = this.engine.time.actualDeltaTime; - const invDeltaTime = 1 / delta; - const velocity = this._velocity; - velocity.set(deltaX * invDeltaTime, deltaY * invDeltaTime); - velocity.scale(Math.pow(this._decelerationRate, delta)) - } - - private _applyScrolling(dx: number, dy: number): void { - const contentPosition = this._contentTransform.position; - const { _contentRect: contentRect, _viewportRect: viewportRect } = this; - // Check if reached edge - contentPosition.set(contentPosition.x + dx, contentPosition.y + dy, contentPosition.z); - const percentH = (viewportRect.x - contentRect.x) / (contentRect.width - viewportRect.width); - const percentV = (contentRect.y + contentRect.height - viewportRect.y - viewportRect.height) / (contentRect.height - viewportRect.height) - const listeners = this._listeners.getLoopArray(); - for (let i = 0, n = listeners.length; i < n; i++) { - const listener = listeners[i]; - !listener.destroyed && listener.fn(percentH, percentV); + if (mode & ScrollViewMode.Vertical) { + if (deltaY > 0) { + if (contentRect.y + deltaY > viewportRect.y) { + deltaY = viewportRect.y - contentRect.y; + } + } else if (deltaY < 0) { + if (contentRect.y + contentRect.height + deltaY < viewportRect.y + viewportRect.height) { + deltaY = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; + } + } } + break; + case MovementType.Elastic: + // Not yet implemented + break; + default: + break; } + this._applyScrolling(deltaX, deltaY); + // Record current velocity for inertia calculation + const delta = this.engine.time.actualDeltaTime; + const invDeltaTime = 1 / delta; + const velocity = this._velocity; + velocity.set(deltaX * invDeltaTime, deltaY * invDeltaTime); + velocity.scale(Math.pow(this._decelerationRate, delta)); + } - override onPointerEndDrag(eventData: PointerEventData): void { - this._isDragging = false; + private _applyScrolling(dx: number, dy: number): void { + const contentPosition = this._contentTransform.position; + const { _contentRect: contentRect, _viewportRect: viewportRect } = this; + // Check if reached edge + contentPosition.set(contentPosition.x + dx, contentPosition.y + dy, contentPosition.z); + const percentH = (viewportRect.x - contentRect.x) / (contentRect.width - viewportRect.width); + const percentV = + (contentRect.y + contentRect.height - viewportRect.y - viewportRect.height) / + (contentRect.height - viewportRect.height); + const listeners = this._listeners.getLoopArray(); + for (let i = 0, n = listeners.length; i < n; i++) { + const listener = listeners[i]; + !listener.destroyed && listener.fn(percentH, percentV); } + } - override onUpdate(deltaTime: number): void { - if (this._isDragging || !this._inertia) return; - const velocity = this._velocity; - if (velocity.lengthSquared() <= this._threshold ** 2) { - velocity.set(0, 0); - return; + override onPointerEndDrag(eventData: PointerEventData): void { + this._isDragging = false; + } + + override onUpdate(deltaTime: number): void { + if (this._isDragging || !this._inertia) return; + const velocity = this._velocity; + if (velocity.lengthSquared() <= this._threshold ** 2) { + velocity.set(0, 0); + return; + } + let dx = 0; + let dy = 0; + const { _inertiaBaseTimeInterval: inertiaBaseTimeInterval, _decelerationRate: decelerationRate } = this; + while (deltaTime > 0) { + const timeSlice = deltaTime > inertiaBaseTimeInterval ? inertiaBaseTimeInterval : deltaTime; + velocity.scale(Math.pow(decelerationRate, timeSlice)); + dx += velocity.x * timeSlice; + dy += velocity.y * timeSlice; + deltaTime -= timeSlice; + } + this._updateRect(); + if (this._movementType !== MovementType.Unrestricted) { + const { _contentRect: contentRect, _viewportRect: viewportRect } = this; + if (dx > 0) { + if (contentRect.x + dx > viewportRect.x) { + dx = viewportRect.x - contentRect.x; + velocity.x = 0; } - let dx = 0; - let dy = 0; - const { _inertiaBaseTimeInterval: inertiaBaseTimeInterval, _decelerationRate: decelerationRate } = this; - while (deltaTime > 0) { - const timeSlice = deltaTime > inertiaBaseTimeInterval ? inertiaBaseTimeInterval : deltaTime; - velocity.scale(Math.pow(decelerationRate, timeSlice)); - dx += velocity.x * timeSlice; - dy += velocity.y * timeSlice; - deltaTime -= timeSlice; + } else if (dx < 0) { + if (contentRect.x + contentRect.width + dx < viewportRect.x + viewportRect.width) { + dx = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; + velocity.x = 0; } - this._updateRect(); - if (this._movementType !== MovementType.Unrestricted) { - const { _contentRect: contentRect, _viewportRect: viewportRect } = this; - if (dx > 0) { - if (contentRect.x + dx > viewportRect.x) { - dx = viewportRect.x - contentRect.x; - velocity.x = 0; - } - } else if (dx < 0) { - if (contentRect.x + contentRect.width + dx < viewportRect.x + viewportRect.width) { - dx = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; - velocity.x = 0; - } - } - if (dy > 0) { - if (contentRect.y + dy > viewportRect.y) { - dy = viewportRect.y - contentRect.y; - velocity.y = 0; - } - } else if (dy < 0) { - if (contentRect.y + contentRect.height + dy < viewportRect.y + viewportRect.height) { - dy = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; - velocity.y = 0; - } - } + } + if (dy > 0) { + if (contentRect.y + dy > viewportRect.y) { + dy = viewportRect.y - contentRect.y; + velocity.y = 0; } - this._applyScrolling(dx, dy); - - } - - private _updateRect(): void { - const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; - if (viewportTransform && contentTransform) { - const { _viewportRect: viewportRect, _contentRect: contentRect } = this; - // Viewport local rect - const viewportPivot = viewportTransform.pivot; - const viewportSize = viewportTransform.size; - viewportRect.set(-viewportPivot.x * viewportSize.x, -viewportPivot.y * viewportSize.y, viewportSize.x, viewportSize.y); - // Content rect relative to viewport - const contentPosition = contentTransform.position; - const contentScale = contentTransform.scale; - const contentPivot = contentTransform.pivot; - const contentSize = contentTransform.size; - contentRect.set( - contentPosition.x - contentPivot.x * contentSize.x * contentScale.x, - contentPosition.y - contentPivot.y * contentSize.y * contentScale.y, - contentSize.x * contentScale.x, - contentSize.y * contentScale.y - ); + } else if (dy < 0) { + if (contentRect.y + contentRect.height + dy < viewportRect.y + viewportRect.height) { + dy = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; + velocity.y = 0; } + } } + this._applyScrolling(dx, dy); + } - override onDestroy(): void { - super.onDestroy(); - this._listeners.findAndRemove((value) => (value.destroyed = true)); + private _updateRect(): void { + const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; + if (viewportTransform && contentTransform) { + const { _viewportRect: viewportRect, _contentRect: contentRect } = this; + // Viewport local rect + const viewportPivot = viewportTransform.pivot; + const viewportSize = viewportTransform.size; + viewportRect.set( + -viewportPivot.x * viewportSize.x, + -viewportPivot.y * viewportSize.y, + viewportSize.x, + viewportSize.y + ); + // Content rect relative to viewport + const contentPosition = contentTransform.position; + const contentScale = contentTransform.scale; + const contentPivot = contentTransform.pivot; + const contentSize = contentTransform.size; + contentRect.set( + contentPosition.x - contentPivot.x * contentSize.x * contentScale.x, + contentPosition.y - contentPivot.y * contentSize.y * contentScale.y, + contentSize.x * contentScale.x, + contentSize.y * contentScale.y + ); } + } + + override onDestroy(): void { + super.onDestroy(); + this._listeners.findAndRemove((value) => (value.destroyed = true)); + } } export enum MovementType { - Elastic, - Clamped, - Unrestricted + Elastic, + Clamped, + Unrestricted } export interface IScrollListener { - fn: (percentH: number, percentV: number) => void; - destroyed?: boolean; + fn: (percentH: number, percentV: number) => void; + destroyed?: boolean; } export enum ScrollViewMode { - Vertical = 0x1, - Horizontal = 0x2, - VerticalAndHorizontal = 0x3 + Vertical = 0x1, + Horizontal = 0x2, + VerticalAndHorizontal = 0x3 } From 15a08bf8b0e257146a7efdf0b639f56604145023 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Sun, 8 Mar 2026 22:04:19 +0800 Subject: [PATCH 08/10] feat: add e2e for rect mask --- e2e/case/ui-rectMask-scrollView.ts | 139 +++++ e2e/config.ts | 11 +- .../originImage/UI_ui-rectMask-scrollView.jpg | 3 + tests/src/ui/RectMask2D.test.ts | 500 ++++++++++++++++-- 4 files changed, 604 insertions(+), 49 deletions(-) create mode 100644 e2e/case/ui-rectMask-scrollView.ts create mode 100644 e2e/fixtures/originImage/UI_ui-rectMask-scrollView.jpg diff --git a/e2e/case/ui-rectMask-scrollView.ts b/e2e/case/ui-rectMask-scrollView.ts new file mode 100644 index 0000000000..e7a98b4126 --- /dev/null +++ b/e2e/case/ui-rectMask-scrollView.ts @@ -0,0 +1,139 @@ +/** + * @title UI RectMask ScrollView + * @category UI + */ +import { Camera, Color, Sprite, Texture2D, TextureFormat, WebGLEngine } from "@galacean/engine"; +import { + CanvasRenderMode, + Image, + RectMask2D, + ScrollView, + ScrollViewMode, + Text, + UICanvas, + UITransform +} from "../../packages/ui/dist/module.js"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.03, 0.04, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceCamera; + uiCanvas.camera = camera; + uiCanvas.referenceResolutionPerUnit = 1; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + const frameEntity = canvasEntity.createChild("Frame"); + const frameTransform = frameEntity.transform as UITransform; + frameTransform.size.set(520, 420); + frameTransform.setPosition(-170, 20, 0); + const frameBackground = frameEntity.addComponent(Image); + frameBackground.sprite = solidSprite; + frameBackground.color.set(0.09, 0.11, 0.15, 1); + + const viewportEntity = frameEntity.createChild("Viewport"); + const viewportTransform = viewportEntity.transform as UITransform; + viewportTransform.size.set(440, 320); + viewportTransform.setPosition(30, -10, 0); + const viewportBackground = viewportEntity.addComponent(Image); + viewportBackground.sprite = solidSprite; + viewportBackground.color.set(0.17, 0.18, 0.2, 1); + + const rectMask = viewportEntity.addComponent(RectMask2D); + rectMask.alphaClip = true; + + const contentEntity = viewportEntity.createChild("Content"); + const contentTransform = contentEntity.transform as UITransform; + contentTransform.size.set(740, 560); + contentTransform.setPosition(90, -70, 0); + + const scrollView = viewportEntity.addComponent(ScrollView); + scrollView.mode = ScrollViewMode.VerticalAndHorizontal; + scrollView.viewport = viewportEntity; + scrollView.content = contentEntity; + + const colors = [ + new Color(0.91, 0.3, 0.24, 1), + new Color(0.16, 0.5, 0.73, 1), + new Color(0.18, 0.8, 0.44, 1), + new Color(0.95, 0.61, 0.07, 1), + new Color(0.56, 0.27, 0.68, 1), + new Color(0.2, 0.6, 0.86, 1), + new Color(0.83, 0.33, 0.33, 1), + new Color(0.1, 0.74, 0.61, 1), + new Color(0.93, 0.78, 0.0, 1) + ]; + + const tileWidth = 180; + const tileHeight = 180; + const gap = 10; + + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + const index = row * 3 + col; + const tileEntity = contentEntity.createChild(`Tile_${index}`); + const tileTransform = tileEntity.transform as UITransform; + tileTransform.size.set(tileWidth, tileHeight); + tileTransform.setPosition(col * (tileWidth + gap) - 170, 170 - row * (tileHeight + gap), 0); + + const tile = tileEntity.addComponent(Image); + tile.sprite = solidSprite; + tile.color = colors[index]; + + const labelEntity = tileEntity.createChild("Label"); + const labelTransform = labelEntity.transform as UITransform; + labelTransform.size.set(tileWidth, tileHeight); + const label = labelEntity.addComponent(Text); + label.text = `${index + 1}`; + label.fontSize = 56; + label.color.set(1, 1, 1, 1); + } + } + + const titleEntity = canvasEntity.createChild("Title"); + const titleTransform = titleEntity.transform as UITransform; + titleTransform.size.set(620, 70); + titleTransform.setPosition(-160, 250, 0); + const title = titleEntity.addComponent(Text); + title.text = "RectMask2D keeps Image and Text visible"; + title.fontSize = 38; + title.color.set(0.96, 0.97, 0.99, 1); + + const noteEntity = canvasEntity.createChild("Note"); + const noteTransform = noteEntity.transform as UITransform; + noteTransform.size.set(340, 180); + noteTransform.setPosition(290, 10, 0); + const noteCard = noteEntity.addComponent(Image); + noteCard.sprite = solidSprite; + noteCard.color.set(0.08, 0.09, 0.12, 1); + + const noteTextEntity = noteEntity.createChild("Copy"); + const noteTextTransform = noteTextEntity.transform as UITransform; + noteTextTransform.size.set(260, 120); + noteTextTransform.setPosition(0, 0, 0); + const noteText = noteTextEntity.addComponent(Text); + noteText.text = "Mask on.\nTiles still render.\nNumbers still render."; + noteText.fontSize = 28; + noteText.color.set(0.77, 0.82, 0.89, 1); + + updateForE2E(engine); + initScreenshot(engine, camera); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1, TextureFormat.R8G8B8A8, false); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/e2e/config.ts b/e2e/config.ts index 587b5f03ea..f3317a5a46 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -376,8 +376,7 @@ export const E2E_CONFIG = { caseFileName: "particleRenderer-emit-mesh-no-shape-world", threshold: 0, diffPercentage: 0.00146 - } - , + }, particleEmitMeshConeScale3DRotation: { category: "Particle", caseFileName: "particleRenderer-emit-mesh-cone-scale-3d-rotation", @@ -457,6 +456,14 @@ export const E2E_CONFIG = { diffPercentage: 0.0 } }, + UI: { + RectMaskScrollView: { + category: "UI", + caseFileName: "ui-rectMask-scrollView", + threshold: 0, + diffPercentage: 0 + } + }, Trail: { basic: { category: "Trail", diff --git a/e2e/fixtures/originImage/UI_ui-rectMask-scrollView.jpg b/e2e/fixtures/originImage/UI_ui-rectMask-scrollView.jpg new file mode 100644 index 0000000000..a079ff329b --- /dev/null +++ b/e2e/fixtures/originImage/UI_ui-rectMask-scrollView.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62e0d9fd1b1fc9d98d52807133f0b0b2c43056c8622cd925672b4726c3fcde06 +size 31885 diff --git a/tests/src/ui/RectMask2D.test.ts b/tests/src/ui/RectMask2D.test.ts index 1b277d6e54..1b288e7f6e 100644 --- a/tests/src/ui/RectMask2D.test.ts +++ b/tests/src/ui/RectMask2D.test.ts @@ -1,99 +1,505 @@ -import { PointerEventData, Script, Sprite, Texture2D } from "@galacean/engine-core"; +import { + Camera, + Entity, + PointerEventData, + RenderTarget, + Script, + Sprite, + Texture2D, + TextureFormat +} from "@galacean/engine-core"; +import { Vector2, Vector3, Vector4 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine-rhi-webgl"; -import { CanvasRenderMode, Image, RectMask2D, UICanvas, UITransform } from "@galacean/engine-ui"; -import { describe, expect, it } from "vitest"; +import { CanvasRenderMode, Image, RectMask2D, Text, UICanvas, UIRenderer, UITransform } from "@galacean/engine-ui"; +import { afterEach, describe, expect, it } from "vitest"; -describe("RectMask2D", async () => { +interface UIFixture { + camera: Camera; + canvasDOM: HTMLCanvasElement; + canvasEntity: Entity; + colorTexture?: Texture2D; + engine: WebGLEngine; + height: number; + inputManager: WebGLEngine["inputManager"]; + renderTarget?: RenderTarget; + root: Entity; + width: number; +} + +const fixtures: UIFixture[] = []; + +describe("RectMask2D", () => { + afterEach(() => { + while (fixtures.length > 0) { + const fixture = fixtures.pop()!; + fixture.engine.destroy(); + fixture.canvasDOM.remove(); + } + }); + + it("should return false when mask size is zero", async () => { + const fixture = await createUIFixture(); + const maskEntity = fixture.root.createChild("mask"); + const rectMask = maskEntity.addComponent(RectMask2D); + const transform = maskEntity.transform as UITransform; + transform.size.set(0, 100); + const worldRect = new Vector4(); + + expect(rectMask._getWorldRect(worldRect)).toBe(false); + }); + + it("should clamp negative softness values", async () => { + const fixture = await createUIFixture(); + const rectMask = fixture.root.createChild("mask").addComponent(RectMask2D); + + rectMask.softness.set(-4, 6); + expect(rectMask.softness.x).toBe(0); + expect(rectMask.softness.y).toBe(6); + + rectMask.softness = new Vector2(5, -3); + expect(rectMask.softness.x).toBe(5); + expect(rectMask.softness.y).toBe(0); + }); + + it("should compute world rect from parent transform scale and translation", async () => { + const fixture = await createUIFixture(); + const container = fixture.root.createChild("container"); + container.transform.setPosition(40, -30, 0); + container.transform.setScale(2, 1.5, 1); + + const maskEntity = container.createChild("mask"); + const rectMask = maskEntity.addComponent(RectMask2D); + const transform = maskEntity.transform as UITransform; + transform.pivot.set(0, 0); + transform.size.set(50, 40); + const worldRect = new Vector4(); + + expect(rectMask._getWorldRect(worldRect)).toBe(true); + const canvasRect = getUIWorldRect(fixture.canvasEntity.transform as UITransform); + expectVector4Close(worldRect, [canvasRect.x + 40, canvasRect.y - 30, canvasRect.x + 140, canvasRect.y + 30]); + }); + + it("should update contains result after transform changes", async () => { + const fixture = await createUIFixture(); + const maskEntity = fixture.canvasEntity.createChild("mask"); + const rectMask = maskEntity.addComponent(RectMask2D); + const transform = maskEntity.transform as UITransform; + transform.size.set(100, 80); + transform.setPosition(20, -10, 0); + const initialWorldRect = new Vector4(); + rectMask._getWorldRect(initialWorldRect); + + expect( + rectMask._containsWorldPoint( + new Vector3((initialWorldRect.x + initialWorldRect.z) * 0.5, (initialWorldRect.y + initialWorldRect.w) * 0.5, 0) + ) + ).toBe(true); + expect(rectMask._containsWorldPoint(new Vector3(initialWorldRect.x - 5, initialWorldRect.y - 5, 0))).toBe(false); + + transform.setPosition(80, 0, 0); + const movedWorldRect = new Vector4(); + rectMask._getWorldRect(movedWorldRect); + + expect( + rectMask._containsWorldPoint( + new Vector3((initialWorldRect.x + initialWorldRect.z) * 0.5, (initialWorldRect.y + initialWorldRect.w) * 0.5, 0) + ) + ).toBe(false); + expect( + rectMask._containsWorldPoint( + new Vector3((movedWorldRect.x + movedWorldRect.z) * 0.5, (movedWorldRect.y + movedWorldRect.w) * 0.5, 0) + ) + ).toBe(true); + }); + + it("should only raycast within rect mask area", async () => { + const fixture = await createUIFixture(); + const { clickScript, clickAtNormalizedPosition } = createRaycastFixture(fixture); + + clickScript.clickCount = 0; + + clickAtNormalizedPosition(0.5, 0.5); + expect(clickScript.clickCount).toBe(1); + + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(1); + }); + + it("should update raycast result when rect mask size changes", async () => { + const fixture = await createUIFixture(); + const { clickScript, clickAtNormalizedPosition, rectMaskTransform } = createRaycastFixture(fixture); + + clickScript.clickCount = 0; + + rectMaskTransform.size.set(100, 100); + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(0); + + rectMaskTransform.size.set(300, 300); + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(1); + }); + + it("should update raycast result when rect mask enabled state changes", async () => { + const fixture = await createUIFixture(); + const { clickScript, clickAtNormalizedPosition, rectMask, rectMaskTransform } = createRaycastFixture(fixture); + + rectMaskTransform.size.set(100, 100); + clickScript.clickCount = 0; + + rectMask.enabled = false; + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(1); + + rectMask.enabled = true; + clickScript.clickCount = 0; + clickAtNormalizedPosition(0.15, 0.15); + expect(clickScript.clickCount).toBe(0); + }); + + it("should upload nested rect mask intersection and hard clip state to image and text renderers", async () => { + const fixture = await createUIFixture(); + const parentEntity = fixture.canvasEntity.createChild("parentMask"); + const parentTransform = parentEntity.transform as UITransform; + parentTransform.size.set(180, 100); + const parentMask = parentEntity.addComponent(RectMask2D); + parentMask.softness.set(2, 3); + + const childEntity = parentEntity.createChild("childMask"); + const childTransform = childEntity.transform as UITransform; + childTransform.size.set(130, 150); + childTransform.setPosition(15, 15, 0); + const childMask = childEntity.addComponent(RectMask2D); + childMask.softness.set(7, 11); + childMask.alphaClip = true; + + const imageEntity = childEntity.createChild("image"); + const imageTransform = imageEntity.transform as UITransform; + imageTransform.size.set(160, 160); + const image = imageEntity.addComponent(Image); + image.sprite = createSolidSprite(fixture.engine); + + const textEntity = childEntity.createChild("text"); + const textTransform = textEntity.transform as UITransform; + textTransform.size.set(120, 80); + const text = textEntity.addComponent(Text); + text.text = "Mask"; + text.fontSize = 40; + + renderFramesToTarget(fixture); + + const expectedRect = intersectWorldRects(parentMask, childMask); + const expectedSoftness: [number, number, number, number] = [7, 3, 7, 3]; + assertRectMaskState(image, expectedRect, expectedSoftness, true); + assertRectMaskState(text, expectedRect, expectedSoftness, true); + }); + + it("should clear clip softness and hard clip state when every mask becomes inactive", async () => { + const fixture = await createUIFixture(); + const maskEntity = fixture.canvasEntity.createChild("mask"); + const maskTransform = maskEntity.transform as UITransform; + maskTransform.size.set(160, 160); + const rectMask = maskEntity.addComponent(RectMask2D); + rectMask.softness.set(6, 4); + rectMask.alphaClip = true; + + const imageEntity = maskEntity.createChild("image"); + const imageTransform = imageEntity.transform as UITransform; + imageTransform.size.set(120, 120); + const image = imageEntity.addComponent(Image); + image.sprite = createSolidSprite(fixture.engine); + + renderFrames(fixture.engine); + const worldRect = new Vector4(); + rectMask._getWorldRect(worldRect); + assertRectMaskState(image, [worldRect.x, worldRect.y, worldRect.z, worldRect.w], [6, 4, 6, 4], true); + + rectMask.enabled = false; + renderFrames(fixture.engine); + + expect(image.shaderData.getFloat(getRectClipEnabledProperty())).toBe(0); + expect(image.shaderData.getFloat(getRectClipHardClipProperty())).toBe(0); + expectVector4Close(image.shaderData.getVector4(getRectClipSoftnessProperty()), [0, 0, 0, 0]); + }); + + it("should keep translated child image visible after rect mask clipping", async () => { + const fixture = await createUIFixture({ width: 256, height: 256 }); + const solidSprite = createSolidSprite(fixture.engine); + + const viewportEntity = fixture.canvasEntity.createChild("viewport"); + const viewportTransform = viewportEntity.transform as UITransform; + viewportTransform.size.set(140, 140); + viewportTransform.setPosition(50, -20, 0); + + const viewportBackground = viewportEntity.addComponent(Image); + viewportBackground.sprite = solidSprite; + viewportBackground.color.set(0.25, 0.25, 0.25, 1); + + const rectMask = viewportEntity.addComponent(RectMask2D); + rectMask.alphaClip = true; + + const imageEntity = viewportEntity.createChild("image"); + const imageTransform = imageEntity.transform as UITransform; + imageTransform.size.set(80, 80); + imageTransform.setPosition(35, 25, 0); + const image = imageEntity.addComponent(Image); + image.sprite = solidSprite; + image.color.set(1, 0, 0, 1); + + renderFrames(fixture.engine); + + const viewportRect = getUIWorldRect(viewportTransform); + const imageRect = getUIWorldRect(imageTransform); + const visibleImageRect = intersectRects(viewportRect, imageRect); + + const insideImage = sampleWorldPixel( + fixture, + (visibleImageRect.x + visibleImageRect.z) * 0.5, + (visibleImageRect.y + visibleImageRect.w) * 0.5 + ); + const insideViewportBackground = sampleWorldPixel( + fixture, + (viewportRect.x + imageRect.x) * 0.5, + (viewportRect.y + viewportRect.w) * 0.5 + ); + const outsideViewport = sampleWorldPixel(fixture, viewportRect.x - 20, viewportRect.w + 20); + expect(insideImage[0]).toBeGreaterThan(220); + expect(insideImage[1]).toBeLessThan(40); + expect(insideImage[2]).toBeLessThan(40); + + expect(insideViewportBackground[0]).toBeGreaterThan(120); + expect(Math.abs(insideViewportBackground[0] - insideViewportBackground[1])).toBeLessThan(10); + expect(Math.abs(insideViewportBackground[1] - insideViewportBackground[2])).toBeLessThan(10); + + expect(outsideViewport[0]).toBeLessThan(20); + expect(outsideViewport[1]).toBeLessThan(20); + expect(outsideViewport[2]).toBeLessThan(20); + }); +}); + +async function createUIFixture( + options: { height?: number; width?: number; withRenderTarget?: boolean } = {} +): Promise { + const { width = 300, height = 300, withRenderTarget = false } = options; const body = document.getElementsByTagName("body")[0]; const canvasDOM = document.createElement("canvas"); - canvasDOM.style.width = "18px"; - canvasDOM.style.height = "18px"; + canvasDOM.style.width = `${width}px`; + canvasDOM.style.height = `${height}px`; body.appendChild(canvasDOM); const engine = await WebGLEngine.create({ canvas: canvasDOM }); const webCanvas = engine.canvas; - webCanvas.width = 300; - webCanvas.height = 300; + webCanvas.width = width; + webCanvas.height = height; + const scene = engine.sceneManager.scenes[0]; + scene.background.solidColor.set(0, 0, 0, 1); const root = scene.createRootEntity("root"); - const inputManager = engine.inputManager; + + const cameraEntity = root.createChild("camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + let colorTexture: Texture2D = null; + let renderTarget: RenderTarget = null; + if (withRenderTarget) { + colorTexture = new Texture2D(engine, width, height, TextureFormat.R8G8B8A8, false, false); + renderTarget = new RenderTarget(engine, width, height, colorTexture, TextureFormat.Depth24Stencil8, 1); + camera.renderTarget = renderTarget; + } const canvasEntity = root.createChild("canvas"); const rootCanvas = canvasEntity.addComponent(UICanvas); rootCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; - rootCanvas.referenceResolutionPerUnit = 50; - rootCanvas.referenceResolution.set(300, 300); + rootCanvas.referenceResolutionPerUnit = 1; + rootCanvas.referenceResolution.set(width, height); + + const fixture: UIFixture = { + camera, + canvasDOM, + canvasEntity, + colorTexture, + engine, + height, + inputManager: engine.inputManager, + renderTarget, + root, + width + }; + fixtures.push(fixture); + return fixture; +} +function createRaycastFixture(fixture: UIFixture) { class ClickScript extends Script { clickCount = 0; override onPointerClick(_eventData: PointerEventData): void { - ++this.clickCount; + this.clickCount++; } } - const rectMaskEntity = canvasEntity.createChild("rectMask"); - const rectMaskTransform = rectMaskEntity.transform; + const rectMaskEntity = fixture.canvasEntity.createChild("rectMask"); + const rectMaskTransform = rectMaskEntity.transform as UITransform; rectMaskTransform.size.set(100, 100); const rectMask = rectMaskEntity.addComponent(RectMask2D); const imageEntity = rectMaskEntity.createChild("image"); + const imageTransform = imageEntity.transform as UITransform; + imageTransform.size.set(300, 300); const image = imageEntity.addComponent(Image); - (imageEntity.transform).size.set(300, 300); - image.sprite = new Sprite(engine, new Texture2D(engine, 1, 1)); + image.sprite = createSolidSprite(fixture.engine); const clickScript = imageEntity.addComponent(ClickScript); let pointerId = 0; const clickAtNormalizedPosition = (x: number, y: number): void => { // @ts-ignore - const { _pointerManager: pointerManager } = inputManager; + const { _pointerManager: pointerManager } = fixture.inputManager; const { _target: target } = pointerManager; const rect = target.getBoundingClientRect(); const clientX = rect.left + rect.width * x; const clientY = rect.top + rect.height * y; const id = ++pointerId; target.dispatchEvent(generatePointerEvent("pointerdown", id, clientX, clientY)); - engine.update(); + fixture.engine.update(); target.dispatchEvent(generatePointerEvent("pointerup", id, clientX, clientY)); - engine.update(); + fixture.engine.update(); }; - it("should only raycast within rect mask area", () => { - clickScript.clickCount = 0; + return { clickAtNormalizedPosition, clickScript, image, rectMask, rectMaskTransform }; +} - clickAtNormalizedPosition(0.5, 0.5); - expect(clickScript.clickCount).toBe(1); +function createSolidSprite(engine: WebGLEngine, rgba: [number, number, number, number] = [255, 255, 255, 255]): Sprite { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array(rgba)); + return new Sprite(engine, texture); +} - clickAtNormalizedPosition(0.15, 0.15); - expect(clickScript.clickCount).toBe(1); - }); +function renderFrames(engine: WebGLEngine, frameCount: number = 3): void { + for (let i = 0; i < frameCount; i++) { + engine.update(); + } + // @ts-ignore + engine._hardwareRenderer._gl.finish(); +} - it("should update raycast result when rect mask size changes", () => { - clickScript.clickCount = 0; +function renderFramesToTarget(fixture: UIFixture, frameCount: number = 3): void { + renderFrames(fixture.engine, frameCount); + fixture.camera.render(); + // @ts-ignore + fixture.engine._hardwareRenderer._gl.finish(); +} - rectMaskTransform.size.set(100, 100); - clickAtNormalizedPosition(0.15, 0.15); - expect(clickScript.clickCount).toBe(0); +function sampleWorldPixel(fixture: UIFixture, worldX: number, worldY: number): Uint8Array { + if (fixture.colorTexture) { + return sampleTexturePixel(fixture.colorTexture, fixture.width, fixture.height, worldX, worldY); + } - rectMaskTransform.size.set(300, 300); - clickAtNormalizedPosition(0.15, 0.15); - expect(clickScript.clickCount).toBe(1); - }); + const x = Math.min(Math.max(Math.round(worldX), 0), fixture.width - 1); + const y = Math.min(Math.max(Math.round(worldY), 0), fixture.height - 1); + const buffer = new Uint8Array(4); + // @ts-ignore + const gl = fixture.engine._hardwareRenderer._gl; + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buffer); + return buffer; +} - it("should update raycast result when rect mask enabled state changes", () => { - rectMaskTransform.size.set(100, 100); - clickScript.clickCount = 0; +function sampleTexturePixel( + texture: Texture2D, + width: number, + height: number, + pixelX: number, + pixelY: number +): Uint8Array { + const x = Math.min(Math.max(Math.round(pixelX), 0), width - 1); + const y = Math.min(Math.max(Math.round(pixelY), 0), height - 1); + const buffer = new Uint8Array(4); + texture.getPixelBuffer(x, y, 1, 1, 0, buffer); + return buffer; +} - rectMask.enabled = false; - clickAtNormalizedPosition(0.15, 0.15); - expect(clickScript.clickCount).toBe(1); +function assertRectMaskState( + renderer: UIRenderer, + expectedRect: [number, number, number, number], + expectedSoftness: [number, number, number, number], + expectedHardClip: boolean +): void { + expect(renderer.shaderData.getFloat(getRectClipEnabledProperty())).toBe(1); + expect(renderer.shaderData.getFloat(getRectClipHardClipProperty())).toBe(expectedHardClip ? 1 : 0); + expectVector4Close(renderer.shaderData.getVector4(getRectClipRectProperty()), expectedRect); + expectVector4Close(renderer.shaderData.getVector4(getRectClipSoftnessProperty()), expectedSoftness); +} - rectMask.enabled = true; - clickScript.clickCount = 0; - clickAtNormalizedPosition(0.15, 0.15); - expect(clickScript.clickCount).toBe(0); - }); -}); +function expectVector4Close(vector: Vector4, expected: [number, number, number, number]): void { + expect(vector.x).toBeCloseTo(expected[0], 4); + expect(vector.y).toBeCloseTo(expected[1], 4); + expect(vector.z).toBeCloseTo(expected[2], 4); + expect(vector.w).toBeCloseTo(expected[3], 4); +} + +function getUIWorldRect(transform: UITransform): Vector4 { + const { x: width, y: height } = transform.size; + const { x: pivotX, y: pivotY } = transform.pivot; + const left = -width * pivotX; + const right = width * (1 - pivotX); + const bottom = -height * pivotY; + const top = height * (1 - pivotY); + + const worldMatrix = transform.worldMatrix; + const corner0 = new Vector3(left, bottom, 0); + const corner1 = new Vector3(left, top, 0); + const corner2 = new Vector3(right, bottom, 0); + const corner3 = new Vector3(right, top, 0); + Vector3.transformCoordinate(corner0, worldMatrix, corner0); + Vector3.transformCoordinate(corner1, worldMatrix, corner1); + Vector3.transformCoordinate(corner2, worldMatrix, corner2); + Vector3.transformCoordinate(corner3, worldMatrix, corner3); + + return new Vector4( + Math.min(corner0.x, corner1.x, corner2.x, corner3.x), + Math.min(corner0.y, corner1.y, corner2.y, corner3.y), + Math.max(corner0.x, corner1.x, corner2.x, corner3.x), + Math.max(corner0.y, corner1.y, corner2.y, corner3.y) + ); +} + +function intersectWorldRects(maskA: RectMask2D, maskB: RectMask2D): [number, number, number, number] { + const rectA = new Vector4(); + const rectB = new Vector4(); + maskA._getWorldRect(rectA); + maskB._getWorldRect(rectB); + const rect = intersectRects(rectA, rectB); + return [rect.x, rect.y, rect.z, rect.w]; +} + +function intersectRects(rectA: Vector4, rectB: Vector4): Vector4 { + return new Vector4( + Math.max(rectA.x, rectB.x), + Math.max(rectA.y, rectB.y), + Math.min(rectA.z, rectB.z), + Math.min(rectA.w, rectB.w) + ); +} + +function getRectClipEnabledProperty() { + // @ts-ignore + return UIRenderer._rectClipEnabledProperty; +} + +function getRectClipHardClipProperty() { + // @ts-ignore + return UIRenderer._rectClipHardClipProperty; +} + +function getRectClipRectProperty() { + // @ts-ignore + return UIRenderer._rectClipRectProperty; +} + +function getRectClipSoftnessProperty() { + // @ts-ignore + return UIRenderer._rectClipSoftnessProperty; +} function generatePointerEvent( type: string, From a1c0d6df3fc3a65bd80a5021d0cbe65dd3cb17b6 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 17 Mar 2026 15:34:09 +0800 Subject: [PATCH 09/10] feat: update e2e --- e2e/case/ui-mask.ts | 115 ++++++++ ...-rectMask-scrollView.ts => ui-rectMask.ts} | 23 +- e2e/config.ts | 10 +- e2e/fixtures/originImage/UI_ui-mask.jpg | 3 + ...Mask-scrollView.jpg => UI_ui-rectMask.jpg} | 0 examples/src/ui-scrollview.ts | 164 ----------- .../ui/src/component/advanced/ScrollView.ts | 267 ------------------ packages/ui/src/component/index.ts | 1 - 8 files changed, 130 insertions(+), 453 deletions(-) create mode 100644 e2e/case/ui-mask.ts rename e2e/case/{ui-rectMask-scrollView.ts => ui-rectMask.ts} (85%) create mode 100644 e2e/fixtures/originImage/UI_ui-mask.jpg rename e2e/fixtures/originImage/{UI_ui-rectMask-scrollView.jpg => UI_ui-rectMask.jpg} (100%) delete mode 100644 examples/src/ui-scrollview.ts delete mode 100644 packages/ui/src/component/advanced/ScrollView.ts diff --git a/e2e/case/ui-mask.ts b/e2e/case/ui-mask.ts new file mode 100644 index 0000000000..6834a53cfd --- /dev/null +++ b/e2e/case/ui-mask.ts @@ -0,0 +1,115 @@ +/** + * @title UI Mask + * @category UI + */ +import { + Camera, + Color, + Sprite, + SpriteMaskInteraction, + Texture2D, + TextureFormat, + Vector3, + WebGLEngine +} from "@galacean/engine"; +import { + CanvasRenderMode, + Image, + Mask, + Text, + UICanvas, + UITransform +} from "../../packages/ui/dist/module.js"; +import { initScreenshot, updateForE2E } from "./.mockForE2E"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor = new Color(0.03, 0.04, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = root.createChild("UICanvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceCamera; + uiCanvas.camera = camera; + uiCanvas.referenceResolutionPerUnit = 1; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // --- Left group: VisibleInsideMask --- + const leftGroupEntity = canvasEntity.createChild("LeftGroup"); + const leftGroupTransform = leftGroupEntity.transform as UITransform; + leftGroupTransform.setPosition(-300, 0, 0); + + // Mask (stencil writer) + const maskEntity = leftGroupEntity.createChild("Mask"); + const maskTransform = maskEntity.transform as UITransform; + maskTransform.size.set(300, 300); + const mask = maskEntity.addComponent(Mask); + mask.sprite = solidSprite; + + // Image behind mask (VisibleInsideMask) + const insideImageEntity = leftGroupEntity.createChild("InsideImage"); + const insideImageTransform = insideImageEntity.transform as UITransform; + insideImageTransform.size.set(500, 500); + const insideImage = insideImageEntity.addComponent(Image); + insideImage.sprite = solidSprite; + insideImage.color.set(0.91, 0.3, 0.24, 1); + insideImage.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + + // Label + const leftLabelEntity = leftGroupEntity.createChild("Label"); + const leftLabelTransform = leftLabelEntity.transform as UITransform; + leftLabelTransform.size.set(300, 60); + leftLabelTransform.setPosition(0, -210, 0); + const leftLabel = leftLabelEntity.addComponent(Text); + leftLabel.text = "VisibleInsideMask"; + leftLabel.fontSize = 30; + leftLabel.color.set(1, 1, 1, 1); + + // --- Right group: VisibleOutsideMask --- + const rightGroupEntity = canvasEntity.createChild("RightGroup"); + const rightGroupTransform = rightGroupEntity.transform as UITransform; + rightGroupTransform.setPosition(300, 0, 0); + + // Mask (stencil writer) + const maskEntity2 = rightGroupEntity.createChild("Mask"); + const maskTransform2 = maskEntity2.transform as UITransform; + maskTransform2.size.set(300, 300); + const mask2 = maskEntity2.addComponent(Mask); + mask2.sprite = solidSprite; + + // Image behind mask (VisibleOutsideMask) + const outsideImageEntity = rightGroupEntity.createChild("OutsideImage"); + const outsideImageTransform = outsideImageEntity.transform as UITransform; + outsideImageTransform.size.set(500, 500); + const outsideImage = outsideImageEntity.addComponent(Image); + outsideImage.sprite = solidSprite; + outsideImage.color.set(0.16, 0.5, 0.73, 1); + outsideImage.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; + + // Label + const rightLabelEntity = rightGroupEntity.createChild("Label"); + const rightLabelTransform = rightLabelEntity.transform as UITransform; + rightLabelTransform.size.set(300, 60); + rightLabelTransform.setPosition(0, -210, 0); + const rightLabel = rightLabelEntity.addComponent(Text); + rightLabel.text = "VisibleOutsideMask"; + rightLabel.fontSize = 30; + rightLabel.color.set(1, 1, 1, 1); + + updateForE2E(engine); + initScreenshot(engine, camera); +}); + +function createSolidSprite(engine: WebGLEngine): Sprite { + const texture = new Texture2D(engine, 1, 1, TextureFormat.R8G8B8A8, false); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return new Sprite(engine, texture); +} diff --git a/e2e/case/ui-rectMask-scrollView.ts b/e2e/case/ui-rectMask.ts similarity index 85% rename from e2e/case/ui-rectMask-scrollView.ts rename to e2e/case/ui-rectMask.ts index e7a98b4126..111482bffe 100644 --- a/e2e/case/ui-rectMask-scrollView.ts +++ b/e2e/case/ui-rectMask.ts @@ -1,5 +1,5 @@ /** - * @title UI RectMask ScrollView + * @title UI RectMask2D * @category UI */ import { Camera, Color, Sprite, Texture2D, TextureFormat, WebGLEngine } from "@galacean/engine"; @@ -7,8 +7,6 @@ import { CanvasRenderMode, Image, RectMask2D, - ScrollView, - ScrollViewMode, Text, UICanvas, UITransform @@ -35,6 +33,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const solidSprite = createSolidSprite(engine); + // --- Left: RectMask2D with alphaClip clipping a grid of tiles --- const frameEntity = canvasEntity.createChild("Frame"); const frameTransform = frameEntity.transform as UITransform; frameTransform.size.set(520, 420); @@ -59,11 +58,6 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { contentTransform.size.set(740, 560); contentTransform.setPosition(90, -70, 0); - const scrollView = viewportEntity.addComponent(ScrollView); - scrollView.mode = ScrollViewMode.VerticalAndHorizontal; - scrollView.viewport = viewportEntity; - scrollView.content = contentEntity; - const colors = [ new Color(0.91, 0.3, 0.24, 1), new Color(0.16, 0.5, 0.73, 1), @@ -102,15 +96,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { } } - const titleEntity = canvasEntity.createChild("Title"); - const titleTransform = titleEntity.transform as UITransform; - titleTransform.size.set(620, 70); - titleTransform.setPosition(-160, 250, 0); - const title = titleEntity.addComponent(Text); - title.text = "RectMask2D keeps Image and Text visible"; - title.fontSize = 38; - title.color.set(0.96, 0.97, 0.99, 1); - + // --- Right: description --- const noteEntity = canvasEntity.createChild("Note"); const noteTransform = noteEntity.transform as UITransform; noteTransform.size.set(340, 180); @@ -122,9 +108,8 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const noteTextEntity = noteEntity.createChild("Copy"); const noteTextTransform = noteTextEntity.transform as UITransform; noteTextTransform.size.set(260, 120); - noteTextTransform.setPosition(0, 0, 0); const noteText = noteTextEntity.addComponent(Text); - noteText.text = "Mask on.\nTiles still render.\nNumbers still render."; + noteText.text = "RectMask2D clips\nImage and Text\nby axis-aligned rect."; noteText.fontSize = 28; noteText.color.set(0.77, 0.82, 0.89, 1); diff --git a/e2e/config.ts b/e2e/config.ts index a45d370dd3..385435f90e 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -463,9 +463,15 @@ export const E2E_CONFIG = { } }, UI: { - RectMaskScrollView: { + Mask: { category: "UI", - caseFileName: "ui-rectMask-scrollView", + caseFileName: "ui-mask", + threshold: 0, + diffPercentage: 0 + }, + RectMask2D: { + category: "UI", + caseFileName: "ui-rectMask", threshold: 0, diffPercentage: 0 } diff --git a/e2e/fixtures/originImage/UI_ui-mask.jpg b/e2e/fixtures/originImage/UI_ui-mask.jpg new file mode 100644 index 0000000000..084ee170ed --- /dev/null +++ b/e2e/fixtures/originImage/UI_ui-mask.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f494d703b31619270d55cdccb97581ef6e783d7e2666cec8251b3315b7209a48 +size 29941 diff --git a/e2e/fixtures/originImage/UI_ui-rectMask-scrollView.jpg b/e2e/fixtures/originImage/UI_ui-rectMask.jpg similarity index 100% rename from e2e/fixtures/originImage/UI_ui-rectMask-scrollView.jpg rename to e2e/fixtures/originImage/UI_ui-rectMask.jpg diff --git a/examples/src/ui-scrollview.ts b/examples/src/ui-scrollview.ts deleted file mode 100644 index 1481d901f3..0000000000 --- a/examples/src/ui-scrollview.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @title UI ScrollView - * @category UI - */ -import { - Camera, - Color, - Entity, - Script, - Sprite, - Texture2D, - TextureFormat, - Vector4, - WebGLEngine -} from "@galacean/engine"; -import { - CanvasRenderMode, - Image, - RectMask2D, - ScrollView, - ScrollViewMode, - Text, - UICanvas, - UITransform -} from "@galacean/engine-ui"; - -async function main() { - const engine = await WebGLEngine.create({ canvas: "canvas" }); - engine.canvas.resizeByClientSize(); - window.addEventListener("resize", () => engine.canvas.resizeByClientSize()); - - const scene = engine.sceneManager.activeScene; - const root = scene.createRootEntity("Root"); - - const cameraEntity = root.createChild("Camera"); - cameraEntity.transform.setPosition(0, 0, 10); - cameraEntity.addComponent(Camera); - - const canvasEntity = root.createChild("UICanvas"); - const uiCanvas = canvasEntity.addComponent(UICanvas); - uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; - uiCanvas.referenceResolutionPerUnit = 100; - uiCanvas.referenceResolution.set(800, 600); - - const solidSprite = createSolidSprite(engine); - - // --- Viewport --- - const viewportEntity = canvasEntity.createChild("Viewport"); - const viewportTransform = viewportEntity.transform as UITransform; - viewportTransform.size.set(400, 300); - - const viewportBg = viewportEntity.addComponent(Image); - viewportBg.sprite = solidSprite; - viewportBg.color.set(0.15, 0.15, 0.15, 1); - viewportBg.raycastEnabled = true; - - const rectMask = viewportEntity.addComponent(RectMask2D); - rectMask.alphaClip = true; - - // --- Content (child of viewport) --- - const cols = 4; - const rows = 3; - const tileW = 180; - const tileH = 180; - const gap = 10; - const contentW = cols * (tileW + gap) - gap; - const contentH = rows * (tileH + gap) - gap; - - const contentEntity = viewportEntity.createChild("Content"); - const contentTransform = contentEntity.transform as UITransform; - contentTransform.size.set(contentW, contentH); - - // --- ScrollView script on viewport --- - const scrollView = viewportEntity.addComponent(ScrollView); - scrollView.mode = ScrollViewMode.VerticalAndHorizontal; - scrollView.viewport = viewportEntity; - scrollView.content = contentEntity; - - // --- Tiles --- - const colors = [ - new Color(0.91, 0.3, 0.24, 1), - new Color(0.16, 0.5, 0.73, 1), - new Color(0.18, 0.8, 0.44, 1), - new Color(0.95, 0.61, 0.07, 1), - new Color(0.56, 0.27, 0.68, 1), - new Color(0.2, 0.6, 0.86, 1), - new Color(0.83, 0.33, 0.33, 1), - new Color(0.1, 0.74, 0.61, 1), - new Color(0.93, 0.78, 0.0, 1), - new Color(0.5, 0.55, 0.53, 1), - new Color(0.61, 0.35, 0.71, 1), - new Color(0.2, 0.29, 0.37, 1) - ]; - - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const idx = r * cols + c; - const tileEntity = contentEntity.createChild(`Tile_${idx}`); - const tileTransform = tileEntity.transform as UITransform; - tileTransform.size.set(tileW, tileH); - - // Layout from top-left of content - const x = c * (tileW + gap) - contentW / 2 + tileW / 2; - const y = contentH / 2 - tileH / 2 - r * (tileH + gap); - tileTransform.setPosition(x, y, 0); - - const tileImage = tileEntity.addComponent(Image); - tileImage.sprite = solidSprite; - tileImage.color = colors[idx % colors.length]; - tileImage.raycastEnabled = false; - - const labelEntity = tileEntity.createChild("Label"); - (labelEntity.transform as UITransform).size.set(tileW, tileH); - const label = labelEntity.addComponent(Text); - label.text = `${idx + 1}`; - label.fontSize = 36; - label.color = new Color(1, 1, 1, 1); - label.raycastEnabled = false; - } - } - - // --- Title --- - const titleEntity = canvasEntity.createChild("Title"); - const titleTransform = titleEntity.transform as UITransform; - titleTransform.size.set(400, 50); - titleTransform.setPosition(0, 210, 0); - const title = titleEntity.addComponent(Text); - title.text = "Drag to scroll"; - title.fontSize = 28; - title.color = new Color(1, 1, 1, 1); - title.raycastEnabled = false; - - // Debug: log clip rect and tile world positions - class DebugScript extends Script { - private _logged = false; - override onUpdate(): void { - if (this._logged) return; - this._logged = true; - const debugRect = new Vector4(); - // @ts-ignore - const hasRect = rectMask._getWorldRect(debugRect); - console.log("RectMask2D worldRect:", hasRect, debugRect.x, debugRect.y, debugRect.z, debugRect.w); - const firstTile = contentEntity.children[0]; - if (firstTile) { - const wp = firstTile.transform.worldPosition; - console.log("Tile0 worldPosition:", wp.x, wp.y, wp.z); - } - const vp = viewportEntity.transform.worldPosition; - console.log("Viewport worldPosition:", vp.x, vp.y, vp.z); - console.log("Viewport worldMatrix:", viewportTransform.worldMatrix.elements); - } - } - viewportEntity.addComponent(DebugScript); - - engine.run(); -} - -main(); - -function createSolidSprite(engine: WebGLEngine): Sprite { - const texture = new Texture2D(engine, 1, 1, TextureFormat.R8G8B8A8, false); - texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); - return new Sprite(engine, texture); -} diff --git a/packages/ui/src/component/advanced/ScrollView.ts b/packages/ui/src/component/advanced/ScrollView.ts deleted file mode 100644 index 4f731ad48a..0000000000 --- a/packages/ui/src/component/advanced/ScrollView.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { Entity, ignoreClone, PointerEventData, Rect, SafeLoopArray, Script, Vector2, Vector3 } from "@galacean/engine"; -import { Utils } from "../../Utils"; -import { UITransform } from "../UITransform"; - -/** - * Vertical scroll view - */ -export class ScrollView extends Script { - // Viewport - private _viewportTransform: UITransform; - // Content - private _contentTransform: UITransform; - // Type - private _mode: ScrollViewMode = ScrollViewMode.VerticalAndHorizontal; - // Scroll type - private _movementType: MovementType = MovementType.Clamped; - // Inertia - private _inertia: boolean = true; - // Current scroll velocity - private _velocity: Vector2 = new Vector2(); - // Inertia velocity threshold - private _threshold = 10; - // Inertia deceleration factor - private _decelerationRate: number = 0.05; - // Split each frame into multiple 1/240s time slices for inertia calculation, simulating integration - private _inertiaBaseTimeInterval = 1 / 240; - - // Is dragging - private _isDragging: boolean = false; - // Calculate bounds - private _viewportRect: Rect = new Rect(); - private _contentRect: Rect = new Rect(); - @ignoreClone - private _listeners: SafeLoopArray = new SafeLoopArray(); - - // Drag start point (in viewport local coordinates) - private _startPoint: Vector3 = new Vector3(); - // Move start point for current frame (in viewport local coordinates) - private _fromPoint: Vector3 = new Vector3(); - // Move end point for current frame (in viewport local coordinates) - private _toPoint: Vector3 = new Vector3(); - - get mode(): ScrollViewMode { - return this._mode; - } - - set mode(value: number) { - this._mode = value; - } - - get viewport() { - return this._viewportTransform?.entity; - } - - set viewport(entity: Entity) { - this._viewportTransform = entity.transform as UITransform; - } - - get content(): Entity { - return this._contentTransform?.entity; - } - - set content(entity: Entity) { - this._contentTransform = entity.transform as UITransform; - } - - /** - * Add scroll callback - * @param listener - The listening function - */ - addOnScroll(listener: (percentH: number, percentV: number) => void): void { - this._listeners.push({ fn: listener }); - } - - /** - * Remove scroll callback - * @param listener - The listening function - */ - removeOnScroll(listener: (val: number) => void): void { - this._listeners.findAndRemove((value) => (value.fn === listener ? (value.destroyed = true) : false)); - } - - override onPointerBeginDrag(eventData: PointerEventData): void { - this._isDragging = true; - const { _viewportTransform: viewportTransform } = this; - if (!viewportTransform || !this._contentTransform) return; - // Initialize drag start point - Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, this._startPoint); - this._fromPoint.copyFrom(this._startPoint); - } - - override onPointerDrag(eventData: PointerEventData): void { - const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; - if (!viewportTransform || !contentTransform) return; - const { _fromPoint: fromPoint, _toPoint: toPoint } = this; - // Update toPoint - Utils.screenToLocalPoint(eventData.pointer.position, viewportTransform, toPoint); - // From fromPoint to toPoint - this._updateRect(); - this._computeScrolling(fromPoint, toPoint); - // Update fromPoint - fromPoint.copyFrom(toPoint); - } - - private _computeScrolling(from: Vector3, to: Vector3) { - const { _contentTransform: contentTransform, _mode: mode } = this; - const contentScale = contentTransform.scale; - // Calculate delta based on mode - let deltaX = mode & ScrollViewMode.Horizontal ? (to.x - from.x) * contentScale.x : 0; - let deltaY = mode & ScrollViewMode.Vertical ? (to.y - from.y) * contentScale.y : 0; - // Calculate scroll movement based on scroll type - switch (this._movementType) { - case MovementType.Clamped: - const { _viewportRect: viewportRect, _contentRect: contentRect } = this; - if (mode & ScrollViewMode.Horizontal) { - if (deltaX > 0) { - if (contentRect.x + deltaX > viewportRect.x) { - deltaX = viewportRect.x - contentRect.x; - } - } else if (deltaX < 0) { - if (contentRect.x + contentRect.width + deltaX < viewportRect.x + viewportRect.width) { - deltaX = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; - } - } - } - if (mode & ScrollViewMode.Vertical) { - if (deltaY > 0) { - if (contentRect.y + deltaY > viewportRect.y) { - deltaY = viewportRect.y - contentRect.y; - } - } else if (deltaY < 0) { - if (contentRect.y + contentRect.height + deltaY < viewportRect.y + viewportRect.height) { - deltaY = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; - } - } - } - break; - case MovementType.Elastic: - // Not yet implemented - break; - default: - break; - } - this._applyScrolling(deltaX, deltaY); - // Record current velocity for inertia calculation - const delta = this.engine.time.actualDeltaTime; - const invDeltaTime = 1 / delta; - const velocity = this._velocity; - velocity.set(deltaX * invDeltaTime, deltaY * invDeltaTime); - velocity.scale(Math.pow(this._decelerationRate, delta)); - } - - private _applyScrolling(dx: number, dy: number): void { - const contentPosition = this._contentTransform.position; - const { _contentRect: contentRect, _viewportRect: viewportRect } = this; - // Check if reached edge - contentPosition.set(contentPosition.x + dx, contentPosition.y + dy, contentPosition.z); - const percentH = (viewportRect.x - contentRect.x) / (contentRect.width - viewportRect.width); - const percentV = - (contentRect.y + contentRect.height - viewportRect.y - viewportRect.height) / - (contentRect.height - viewportRect.height); - const listeners = this._listeners.getLoopArray(); - for (let i = 0, n = listeners.length; i < n; i++) { - const listener = listeners[i]; - !listener.destroyed && listener.fn(percentH, percentV); - } - } - - override onPointerEndDrag(eventData: PointerEventData): void { - this._isDragging = false; - } - - override onUpdate(deltaTime: number): void { - if (this._isDragging || !this._inertia) return; - const velocity = this._velocity; - if (velocity.lengthSquared() <= this._threshold ** 2) { - velocity.set(0, 0); - return; - } - let dx = 0; - let dy = 0; - const { _inertiaBaseTimeInterval: inertiaBaseTimeInterval, _decelerationRate: decelerationRate } = this; - while (deltaTime > 0) { - const timeSlice = deltaTime > inertiaBaseTimeInterval ? inertiaBaseTimeInterval : deltaTime; - velocity.scale(Math.pow(decelerationRate, timeSlice)); - dx += velocity.x * timeSlice; - dy += velocity.y * timeSlice; - deltaTime -= timeSlice; - } - this._updateRect(); - if (this._movementType !== MovementType.Unrestricted) { - const { _contentRect: contentRect, _viewportRect: viewportRect } = this; - if (dx > 0) { - if (contentRect.x + dx > viewportRect.x) { - dx = viewportRect.x - contentRect.x; - velocity.x = 0; - } - } else if (dx < 0) { - if (contentRect.x + contentRect.width + dx < viewportRect.x + viewportRect.width) { - dx = viewportRect.x + viewportRect.width - contentRect.x - contentRect.width; - velocity.x = 0; - } - } - if (dy > 0) { - if (contentRect.y + dy > viewportRect.y) { - dy = viewportRect.y - contentRect.y; - velocity.y = 0; - } - } else if (dy < 0) { - if (contentRect.y + contentRect.height + dy < viewportRect.y + viewportRect.height) { - dy = viewportRect.y + viewportRect.height - contentRect.y - contentRect.height; - velocity.y = 0; - } - } - } - this._applyScrolling(dx, dy); - } - - private _updateRect(): void { - const { _viewportTransform: viewportTransform, _contentTransform: contentTransform } = this; - if (viewportTransform && contentTransform) { - const { _viewportRect: viewportRect, _contentRect: contentRect } = this; - // Viewport local rect - const viewportPivot = viewportTransform.pivot; - const viewportSize = viewportTransform.size; - viewportRect.set( - -viewportPivot.x * viewportSize.x, - -viewportPivot.y * viewportSize.y, - viewportSize.x, - viewportSize.y - ); - // Content rect relative to viewport - const contentPosition = contentTransform.position; - const contentScale = contentTransform.scale; - const contentPivot = contentTransform.pivot; - const contentSize = contentTransform.size; - contentRect.set( - contentPosition.x - contentPivot.x * contentSize.x * contentScale.x, - contentPosition.y - contentPivot.y * contentSize.y * contentScale.y, - contentSize.x * contentScale.x, - contentSize.y * contentScale.y - ); - } - } - - override onDestroy(): void { - super.onDestroy(); - this._listeners.findAndRemove((value) => (value.destroyed = true)); - } -} - -export enum MovementType { - Elastic, - Clamped, - Unrestricted -} - -export interface IScrollListener { - fn: (percentH: number, percentV: number) => void; - destroyed?: boolean; -} - -export enum ScrollViewMode { - Vertical = 0x1, - Horizontal = 0x2, - VerticalAndHorizontal = 0x3 -} diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts index 261e2a3fe5..8329d1f39a 100644 --- a/packages/ui/src/component/index.ts +++ b/packages/ui/src/component/index.ts @@ -2,7 +2,6 @@ export { Button } from "./advanced/Button"; export { Image } from "./advanced/Image"; export { Mask } from "./advanced/Mask"; export { RectMask2D } from "./advanced/RectMask2D"; -export { MovementType, ScrollView, ScrollViewMode } from "./advanced/ScrollView"; export { Text } from "./advanced/Text"; export { ColorTransition } from "./interactive/transition/ColorTransition"; export { ScaleTransition } from "./interactive/transition/ScaleTransition"; From 01131703ec01c8da97860c25eae198f0f197ced8 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 17 Mar 2026 19:15:08 +0800 Subject: [PATCH 10/10] feat: add ui mask --- packages/core/src/2d/sprite/MaskRenderable.ts | 128 ++++++++++-------- packages/core/src/2d/sprite/SpriteMask.ts | 6 +- .../core/src/2d/sprite/SpriteMaskUtils.ts | 107 +-------------- packages/ui/src/component/advanced/Mask.ts | 6 +- 4 files changed, 79 insertions(+), 168 deletions(-) diff --git a/packages/core/src/2d/sprite/MaskRenderable.ts b/packages/core/src/2d/sprite/MaskRenderable.ts index 254e8f3d0a..09fa5e147d 100644 --- a/packages/core/src/2d/sprite/MaskRenderable.ts +++ b/packages/core/src/2d/sprite/MaskRenderable.ts @@ -1,6 +1,7 @@ import { BoundingBox, Vector2, Vector3 } from "@galacean/engine-math"; import { BatchUtils } from "../../RenderPipeline/BatchUtils"; import { RenderElement } from "../../RenderPipeline/RenderElement"; +import { RenderQueueFlags } from "../../RenderPipeline/BasicRenderPipeline"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; @@ -12,13 +13,6 @@ import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { Sprite } from "./Sprite"; import { SpriteMaskUtils } from "./SpriteMaskUtils"; -type RendererConstructor = abstract new (...args: any[]) => Renderer; - -/** @internal */ -const _maskTextureProperty = ShaderProperty.getByName("renderer_MaskTexture"); -/** @internal */ -const _alphaCutoffProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - /** * Public contract of the MaskRenderable mixin, used for declaration file generation. */ @@ -38,9 +32,9 @@ export interface IMaskRenderable { _renderMask(distanceForSort: number): void; _onSpriteChange(type: SpriteModifyFlags): void; _onSpriteChangeExtra(type: SpriteModifyFlags): void; - _getMaskWidth(): number; - _getMaskHeight(): number; - _getMaskPivot(): Vector2; + _getSpriteWidth(): number; + _getSpriteHeight(): number; + _getSpritePivot(): Vector2; } /** @@ -50,6 +44,9 @@ export function MaskRenderable( Base: T ): (abstract new (...args: any[]) => IMaskRenderable) & T { abstract class MaskRenderableBase extends Base implements IMaskRenderable { + private static _maskTextureProperty = ShaderProperty.getByName("renderer_MaskTexture"); + private static _alphaCutoffProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + @assignmentClone private _influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; /** @internal */ @@ -120,14 +117,22 @@ export function MaskRenderable( } set sprite(value: Sprite | null) { - this._sprite = SpriteMaskUtils.setSprite( - this, - this._sprite, - value, - this._onSpriteChange, - _maskTextureProperty, - MaskDirtyFlags.All - ); + const lastSprite = this._sprite; + if (lastSprite !== value) { + if (lastSprite) { + this._addResourceReferCount(lastSprite, -1); + lastSprite._updateFlagManager.removeListener(this._onSpriteChange); + } + this._dirtyUpdateFlag |= MaskDirtyFlags.All; + if (value) { + this._addResourceReferCount(value, 1); + value._updateFlagManager.addListener(this._onSpriteChange); + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, value.texture); + } else { + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, null); + } + this._sprite = value; + } } /** @@ -140,7 +145,7 @@ export function MaskRenderable( set alphaCutoff(value: number) { if (this._alphaCutoff !== value) { this._alphaCutoff = value; - this.shaderData.setFloat(_alphaCutoffProperty, value); + this.shaderData.setFloat(MaskRenderableBase._alphaCutoffProperty, value); } } @@ -188,9 +193,9 @@ export function MaskRenderable( worldPoint, this._sprite, this._transformEntity.transform.worldMatrix, - this._getMaskWidth(), - this._getMaskHeight(), - this._getMaskPivot(), + this._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), this._flipX, this._flipY, this._alphaCutoff @@ -205,7 +210,7 @@ export function MaskRenderable( SimpleSpriteAssembler.resetData(this as unknown as ISpriteRenderer); // @ts-ignore this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); - this.shaderData.setFloat(_alphaCutoffProperty, this._alphaCutoff); + this.shaderData.setFloat(MaskRenderableBase._alphaCutoffProperty, this._alphaCutoff); this._renderElement = new RenderElement(); this._renderElement.addSubRenderElement(new SubRenderElement()); this._onSpriteChange = this._onSpriteChange.bind(this); @@ -224,29 +229,36 @@ export function MaskRenderable( * Release mask sprite resources. Called from subclass _onDestroy. */ _destroyMaskResources(): void { - SpriteMaskUtils.releaseSprite(this, this._sprite, this._onSpriteChange); + const sprite = this._sprite; + if (sprite) { + this._addResourceReferCount(sprite, -1); + sprite._updateFlagManager.removeListener(this._onSpriteChange); + } this._sprite = null; this._renderElement = null; } /** * @internal - * Shared update-bounds logic via SpriteMaskUtils. + * Update bounds using SimpleSpriteAssembler directly. */ _updateMaskBounds(worldBounds: BoundingBox): void { - const transform = this._transformEntity.transform; - SpriteMaskUtils.updateBounds( - this as unknown as ISpriteRenderer, - this._sprite, - worldBounds, - transform.worldMatrix, - transform.worldPosition, - this._getMaskWidth(), - this._getMaskHeight(), - this._getMaskPivot(), - this._flipX, - this._flipY - ); + const sprite = this._sprite; + if (sprite) { + SimpleSpriteAssembler.updatePositions( + this as unknown as ISpriteRenderer, + this._transformEntity.transform.worldMatrix, + this._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), + this._flipX, + this._flipY + ); + } else { + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } } /** @@ -255,8 +267,8 @@ export function MaskRenderable( */ _renderMask(distanceForSort: number): void { const { _sprite: sprite } = this; - const width = this._getMaskWidth(); - const height = this._getMaskHeight(); + const width = this._getSpriteWidth(); + const height = this._getSpriteHeight(); if (!sprite?.texture || !width || !height) { return; } @@ -273,12 +285,12 @@ export function MaskRenderable( // Update position if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - SpriteMaskUtils.updatePositions( + SimpleSpriteAssembler.updatePositions( this as unknown as ISpriteRenderer, this._transformEntity.transform.worldMatrix, width, height, - this._getMaskPivot(), + this._getSpritePivot(), this._flipX, this._flipY ); @@ -287,18 +299,18 @@ export function MaskRenderable( // Update uv if (this._dirtyUpdateFlag & MaskDirtyFlags.UV) { - SpriteMaskUtils.updateUVs(this as unknown as ISpriteRenderer); + SimpleSpriteAssembler.updateUVs(this as unknown as ISpriteRenderer); this._dirtyUpdateFlag &= ~MaskDirtyFlags.UV; } - SpriteMaskUtils.setupRenderElement( - this._renderElement, - this, - material, - (this as any)._subChunk, - sprite.texture, - distanceForSort - ); + // Push render element + const subRenderElement = this._renderElement.subRenderElements[0]; + this._renderElement.set(this.priority, distanceForSort); + const subChunk = (this as any)._subChunk; + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, sprite.texture, subChunk); + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; + this._renderElement.addSubRenderElement(subRenderElement); } /** @internal */ @@ -306,7 +318,7 @@ export function MaskRenderable( _onSpriteChange(type: SpriteModifyFlags): void { switch (type) { case SpriteModifyFlags.texture: - this.shaderData.setTexture(_maskTextureProperty, this.sprite.texture); + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, this.sprite.texture); break; case SpriteModifyFlags.region: case SpriteModifyFlags.atlasRegionOffset: @@ -332,15 +344,15 @@ export function MaskRenderable( _onSpriteChangeExtra(type: SpriteModifyFlags): void {} /** @internal */ - _getMaskWidth(): number { + _getSpriteWidth(): number { return 0; } /** @internal */ - _getMaskHeight(): number { + _getSpriteHeight(): number { return 0; } /** @internal */ - _getMaskPivot(): Vector2 { + _getSpritePivot(): Vector2 { return null; } } @@ -355,9 +367,11 @@ export enum MaskDirtyFlags { /** UV. */ UV = 0x2, /** Automatic Size. */ - AutomaticSize = 0x4, + AutomaticSize = 0x8, /** WorldVolume and UV. */ WorldVolumeAndUV = 0x3, /** All. */ - All = 0x7 + All = 0xb } + +type RendererConstructor = abstract new (...args: any[]) => Renderer; diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index e231fec058..7a92adeeb3 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -132,15 +132,15 @@ export class SpriteMask extends MaskRenderable(Renderer) { } } - override _getMaskWidth(): number { + override _getSpriteWidth(): number { return this.width; } - override _getMaskHeight(): number { + override _getSpriteHeight(): number { return this.height; } - override _getMaskPivot() { + override _getSpritePivot() { return this.sprite?.pivot; } diff --git a/packages/core/src/2d/sprite/SpriteMaskUtils.ts b/packages/core/src/2d/sprite/SpriteMaskUtils.ts index 8a78060399..b7033ee800 100644 --- a/packages/core/src/2d/sprite/SpriteMaskUtils.ts +++ b/packages/core/src/2d/sprite/SpriteMaskUtils.ts @@ -1,18 +1,9 @@ -import { BoundingBox, Matrix, Vector2, Vector3 } from "@galacean/engine-math"; -import { Renderer } from "../../Renderer"; -import { RenderQueueFlags } from "../../RenderPipeline/BasicRenderPipeline"; -import { RenderElement } from "../../RenderPipeline/RenderElement"; -import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; -import { ShaderProperty } from "../../shader/ShaderProperty"; -import { Material } from "../../material"; +import { Matrix, Vector2, Vector3 } from "@galacean/engine-math"; import { Texture2D, TextureFormat } from "../../texture"; -import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; -import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; -import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { Sprite } from "./Sprite"; /** - * Internal helpers shared by SpriteMask and UI Mask. + * Internal helpers for sprite mask hit testing. * @internal */ export class SpriteMaskUtils { @@ -26,100 +17,6 @@ export class SpriteMaskUtils { private static _f32Buffer4 = new Float32Array(4); private static _u32Buffer4 = new Uint32Array(4); - static setSprite( - renderer: Renderer, - currentSprite: Sprite | null, - nextSprite: Sprite | null, - onSpriteChange: (type: SpriteModifyFlags) => void, - textureProperty: ShaderProperty, - allDirtyFlag: number - ): Sprite | null { - if (currentSprite === nextSprite) { - return currentSprite; - } - - const target = renderer as any; - if (currentSprite) { - target._addResourceReferCount(currentSprite, -1); - currentSprite._updateFlagManager.removeListener(onSpriteChange); - } - - target._dirtyUpdateFlag |= allDirtyFlag; - if (nextSprite) { - target._addResourceReferCount(nextSprite, 1); - nextSprite._updateFlagManager.addListener(onSpriteChange); - renderer.shaderData.setTexture(textureProperty, nextSprite.texture); - } else { - renderer.shaderData.setTexture(textureProperty, null); - } - - return nextSprite; - } - - static releaseSprite( - renderer: Renderer, - sprite: Sprite | null, - onSpriteChange: (type: SpriteModifyFlags) => void - ): void { - if (sprite) { - const target = renderer as any; - target._addResourceReferCount(sprite, -1); - sprite._updateFlagManager.removeListener(onSpriteChange); - } - } - - static updateBounds( - renderer: ISpriteRenderer, - sprite: Sprite | null, - worldBounds: BoundingBox, - worldMatrix: Matrix, - worldPosition: Vector3, - width: number, - height: number, - pivot: Vector2, - flipX: boolean, - flipY: boolean - ): void { - if (sprite) { - SpriteMaskUtils.updatePositions(renderer, worldMatrix, width, height, pivot, flipX, flipY); - } else { - worldBounds.min.copyFrom(worldPosition); - worldBounds.max.copyFrom(worldPosition); - } - } - - static updatePositions( - renderer: ISpriteRenderer, - worldMatrix: Matrix, - width: number, - height: number, - pivot: Vector2, - flipX: boolean, - flipY: boolean - ): void { - SimpleSpriteAssembler.updatePositions(renderer, worldMatrix, width, height, pivot, flipX, flipY); - } - - static updateUVs(renderer: ISpriteRenderer): void { - SimpleSpriteAssembler.updateUVs(renderer); - } - - static setupRenderElement( - renderElement: RenderElement, - renderer: Renderer, - material: Material, - subChunk: SubPrimitiveChunk, - texture: any, - distanceForSort: number - ): void { - const subRenderElement = renderElement.subRenderElements[0]; - renderElement.set(renderer.priority, distanceForSort); - subRenderElement.set(renderer, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - renderElement.addSubRenderElement(subRenderElement); - } - static containsWorldPoint( worldPoint: Vector3, sprite: Sprite | null, diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts index 98b12e6e13..9c95bc56c7 100644 --- a/packages/ui/src/component/advanced/Mask.ts +++ b/packages/ui/src/component/advanced/Mask.ts @@ -61,15 +61,15 @@ export class Mask extends MaskRenderable(UIRenderer) { } } - override _getMaskWidth(): number { + override _getSpriteWidth(): number { return (this._transformEntity.transform).size.x; } - override _getMaskHeight(): number { + override _getSpriteHeight(): number { return (this._transformEntity.transform).size.y; } - override _getMaskPivot(): Vector2 { + override _getSpritePivot(): Vector2 { return (this._transformEntity.transform).pivot; } }