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.ts b/e2e/case/ui-rectMask.ts new file mode 100644 index 0000000000..111482bffe --- /dev/null +++ b/e2e/case/ui-rectMask.ts @@ -0,0 +1,124 @@ +/** + * @title UI RectMask2D + * @category UI + */ +import { Camera, Color, Sprite, Texture2D, TextureFormat, WebGLEngine } from "@galacean/engine"; +import { + CanvasRenderMode, + Image, + RectMask2D, + 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: RectMask2D with alphaClip clipping a grid of tiles --- + 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 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); + } + } + + // --- Right: description --- + 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); + const noteText = noteTextEntity.addComponent(Text); + noteText.text = "RectMask2D clips\nImage and Text\nby axis-aligned rect."; + 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 f4f205fed8..385435f90e 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -462,6 +462,20 @@ export const E2E_CONFIG = { diffPercentage: 0.0 } }, + UI: { + Mask: { + category: "UI", + caseFileName: "ui-mask", + threshold: 0, + diffPercentage: 0 + }, + RectMask2D: { + category: "UI", + caseFileName: "ui-rectMask", + threshold: 0, + diffPercentage: 0 + } + }, Trail: { basic: { category: "Trail", 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.jpg b/e2e/fixtures/originImage/UI_ui-rectMask.jpg new file mode 100644 index 0000000000..a079ff329b --- /dev/null +++ b/e2e/fixtures/originImage/UI_ui-rectMask.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62e0d9fd1b1fc9d98d52807133f0b0b2c43056c8622cd925672b4726c3fcde06 +size 31885 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/MaskRenderable.ts b/packages/core/src/2d/sprite/MaskRenderable.ts new file mode 100644 index 0000000000..09fa5e147d --- /dev/null +++ b/packages/core/src/2d/sprite/MaskRenderable.ts @@ -0,0 +1,377 @@ +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"; +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"; + +/** + * Public contract of the MaskRenderable mixin, used for declaration file generation. + */ +export interface IMaskRenderable { + 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; + _getSpriteWidth(): number; + _getSpriteHeight(): number; + _getSpritePivot(): 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 IMaskRenderable { + private static _maskTextureProperty = ShaderProperty.getByName("renderer_MaskTexture"); + private static _alphaCutoffProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + + @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) { + 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; + } + } + + /** + * 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(MaskRenderableBase._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._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), + 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(MaskRenderableBase._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 { + const sprite = this._sprite; + if (sprite) { + this._addResourceReferCount(sprite, -1); + sprite._updateFlagManager.removeListener(this._onSpriteChange); + } + this._sprite = null; + this._renderElement = null; + } + + /** + * @internal + * Update bounds using SimpleSpriteAssembler directly. + */ + _updateMaskBounds(worldBounds: BoundingBox): void { + 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); + } + } + + /** + * @internal + * Shared render logic for mask geometry. + */ + _renderMask(distanceForSort: number): void { + const { _sprite: sprite } = this; + const width = this._getSpriteWidth(); + const height = this._getSpriteHeight(); + 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) { + SimpleSpriteAssembler.updatePositions( + this as unknown as ISpriteRenderer, + this._transformEntity.transform.worldMatrix, + width, + height, + this._getSpritePivot(), + this._flipX, + this._flipY + ); + this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; + } + + // Update uv + if (this._dirtyUpdateFlag & MaskDirtyFlags.UV) { + SimpleSpriteAssembler.updateUVs(this as unknown as ISpriteRenderer); + this._dirtyUpdateFlag &= ~MaskDirtyFlags.UV; + } + + // 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 */ + @ignoreClone + _onSpriteChange(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.texture: + this.shaderData.setTexture(MaskRenderableBase._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 */ + _getSpriteWidth(): number { + return 0; + } + /** @internal */ + _getSpriteHeight(): number { + return 0; + } + /** @internal */ + _getSpritePivot(): 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 = 0x8, + /** WorldVolume and UV. */ + WorldVolumeAndUV = 0x3, + /** All. */ + 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 b179899b8e..7a92adeeb3 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -1,46 +1,25 @@ import { BoundingBox } 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"; -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 { 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 - 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,13 +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; /** * Render width (in world coordinates). @@ -69,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; } } @@ -92,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; } } @@ -104,85 +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) { - 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; - } - } - - /** - * 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(); } /** @@ -198,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); } /** @@ -239,147 +108,64 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { } 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); - } + 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) { - SimpleSpriteAssembler.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) { - SimpleSpriteAssembler.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); + this._renderMask(this._distanceForSort); } /** * @inheritdoc */ protected override _onDestroy(): void { - const sprite = this._sprite; - if (sprite) { - this._addResourceReferCount(sprite, -1); - sprite._updateFlagManager.removeListener(this._onSpriteChange); - } + this._destroyMaskResources(); super._onDestroy(); - this._sprite = null; if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); this._subChunk = null; } + } - this._renderElement = null; + override _getSpriteWidth(): 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 _getSpriteHeight(): number { + return this.height; } - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { + override _getSpritePivot() { + 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/SpriteMaskUtils.ts b/packages/core/src/2d/sprite/SpriteMaskUtils.ts new file mode 100644 index 0000000000..b7033ee800 --- /dev/null +++ b/packages/core/src/2d/sprite/SpriteMaskUtils.ts @@ -0,0 +1,136 @@ +import { Matrix, Vector2, Vector3 } from "@galacean/engine-math"; +import { Texture2D, TextureFormat } from "../../texture"; +import { Sprite } from "./Sprite"; + +/** + * Internal helpers for sprite mask hit testing. + * @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 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..c48fb9261b 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -1,3 +1,6 @@ +export type { IMaskRenderable } from "./MaskRenderable"; +export { MaskDirtyFlags, MaskRenderable } from "./MaskRenderable"; 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..8663277a96 100644 --- a/packages/core/src/RenderPipeline/MaskManager.ts +++ b/packages/core/src/RenderPipeline/MaskManager.ts @@ -1,4 +1,6 @@ -import { SpriteMask } from "../2d"; +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"; import { Material } from "../material"; @@ -28,17 +30,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: IMaskRenderable): void { mask._maskIndex = this._allSpriteMasks.length; this._allSpriteMasks.add(mask); + this._setFilteredMasksDirty(); } - removeSpriteMask(mask: SpriteMask): void { + removeSpriteMask(mask: IMaskRenderable): 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 +152,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): IMaskRenderable[] { + 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/RenderPipeline/index.ts b/packages/core/src/RenderPipeline/index.ts index 7161b57757..d4e920744e 100644 --- a/packages/core/src/RenderPipeline/index.ts +++ b/packages/core/src/RenderPipeline/index.ts @@ -1,5 +1,7 @@ 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/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..c3971d0172 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,6 +7,7 @@ attribute vec4 COLOR_0; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; void main() { @@ -13,4 +15,5 @@ void main() v_uv = TEXCOORD_0; v_color = COLOR_0; + v_worldPosition = POSITION.xy; } diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index f47e0a8053..5e54dc5371 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(); + /** + * 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) { + // @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; + // Calculate 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: + // World space not yet supported, see issue #2793 + return false; + } + // 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); + 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/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index e8b09e7a13..e3ca39d499 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -25,6 +25,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 { /** @internal */ static _hierarchyCounter: number = 1; private static _tempGroupAbleList: IGroupAble[] = []; + private static _tempRectMaskList: RectMask2D[] = []; private static _tempVec3: Vector3 = new Vector3(); private static _tempMat: Matrix = new Matrix(); @@ -425,7 +427,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; @@ -507,10 +510,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]; @@ -522,11 +533,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); @@ -536,10 +550,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 285df1d122..6195d97224 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, @@ -21,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"; @@ -37,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. @@ -71,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; @@ -90,6 +118,30 @@ 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. */ @@ -112,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 @@ -137,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. @@ -239,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 */ @@ -254,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; @@ -280,6 +351,161 @@ 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); @@ -289,6 +515,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 new file mode 100644 index 0000000000..9c95bc56c7 --- /dev/null +++ b/packages/ui/src/component/advanced/Mask.ts @@ -0,0 +1,75 @@ +import { BoundingBox, Entity, MaskRenderable, Vector2 } from "@galacean/engine"; +import type { IMaskRenderable } from "@galacean/engine"; +import { UIRenderer, UITransform } from ".."; + +export class Mask extends MaskRenderable(UIRenderer) { + /** + * @internal + */ + override _getChunkManager() { + // @ts-ignore + return this.engine._batcherManager.primitiveChunkManagerMask; + } + + /** + * @internal + */ + constructor(entity: Entity) { + super(entity); + this._initMask(); + this.raycastEnabled = false; + } + + /** + * @internal + */ + _cloneTo(target: Mask, srcRoot: Entity, targetRoot: Entity): void { + // @ts-ignore + super._cloneTo(target, srcRoot, targetRoot); + this._cloneMaskData(target); + } + + protected override _updateBounds(worldBounds: BoundingBox): void { + const rootCanvas = this._getRootCanvas(); + if (this.sprite && rootCanvas) { + this._updateMaskBounds(worldBounds); + } else { + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } + } + + /** + * @inheritdoc + */ + protected override _render(context): void { + this._renderMask(0); + } + + /** + * @inheritdoc + */ + protected override _onDestroy(): void { + this._destroyMaskResources(); + + super._onDestroy(); + + if (this._subChunk) { + this._getChunkManager().freeSubChunk(this._subChunk); + this._subChunk = null; + } + } + + override _getSpriteWidth(): number { + return (this._transformEntity.transform).size.x; + } + + override _getSpriteHeight(): number { + return (this._transformEntity.transform).size.y; + } + + override _getSpritePivot(): Vector2 { + return (this._transformEntity.transform).pivot; + } +} 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/Text.ts b/packages/ui/src/component/advanced/Text.ts index d3371ea43f..4f6800117a 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..8329d1f39a 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 { RectMask2D } from "./advanced/RectMask2D"; 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"; 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..52345d9abf 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,12 @@ attribute vec4 COLOR_0; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; void main() { gl_Position = renderer_MVPMat * vec4(POSITION, 1.0); v_uv = TEXCOORD_0; v_color = COLOR_0; + v_worldPosition = POSITION.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..1b288e7f6e --- /dev/null +++ b/tests/src/ui/RectMask2D.test.ts @@ -0,0 +1,513 @@ +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, Text, UICanvas, UIRenderer, UITransform } from "@galacean/engine-ui"; +import { afterEach, describe, expect, it } from "vitest"; + +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 = `${width}px`; + canvasDOM.style.height = `${height}px`; + body.appendChild(canvasDOM); + + const engine = await WebGLEngine.create({ canvas: canvasDOM }); + const webCanvas = engine.canvas; + 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 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 = 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++; + } + } + + 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); + 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 } = 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)); + fixture.engine.update(); + target.dispatchEvent(generatePointerEvent("pointerup", id, clientX, clientY)); + fixture.engine.update(); + }; + + return { clickAtNormalizedPosition, clickScript, image, rectMask, rectMaskTransform }; +} + +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); +} + +function renderFrames(engine: WebGLEngine, frameCount: number = 3): void { + for (let i = 0; i < frameCount; i++) { + engine.update(); + } + // @ts-ignore + engine._hardwareRenderer._gl.finish(); +} + +function renderFramesToTarget(fixture: UIFixture, frameCount: number = 3): void { + renderFrames(fixture.engine, frameCount); + fixture.camera.render(); + // @ts-ignore + fixture.engine._hardwareRenderer._gl.finish(); +} + +function sampleWorldPixel(fixture: UIFixture, worldX: number, worldY: number): Uint8Array { + if (fixture.colorTexture) { + return sampleTexturePixel(fixture.colorTexture, fixture.width, fixture.height, worldX, worldY); + } + + 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; +} + +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; +} + +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); +} + +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, + pointerId: number, + clientX: number, + clientY: number, + button: number = 0, + buttons: number = 1 +) { + return new PointerEvent(type, { pointerId, clientX, clientY, button, buttons }); +}