diff --git a/examples/src/sprite-mask.ts b/examples/src/sprite-mask.ts new file mode 100644 index 000000000..f8dc71910 --- /dev/null +++ b/examples/src/sprite-mask.ts @@ -0,0 +1,87 @@ +/** + * @title Sprite Mask + * @category 2D + */ +import { + AssetType, + Camera, + Sprite, + SpriteMask, + SpriteMaskInteraction, + SpriteMaskLayer, + SpriteRenderer, + Texture2D, + WebGLEngine +} from "@galacean/engine"; + +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor.set(0.05, 0.05, 0.07, 1); + const root = scene.createRootEntity("Root"); + + const cameraEntity = root.createChild("Camera"); + cameraEntity.transform.setPosition(0, 0, 50); + cameraEntity.addComponent(Camera); + + engine.resourceManager + .load({ + url: "https://gw.alipayobjects.com/mdn/rms_7c464e/afts/img/A*ApFPTZSqcMkAAAAAAAAAAAAAARQnAQ", + type: AssetType.Texture2D + }) + .then((texture) => { + const sprite = new Sprite(engine, texture); + const maskSprite = new Sprite(engine, createSolidTexture(engine)); + + const spriteWidth = sprite.width; + const spriteHeight = sprite.height; + // Mask covers ~half of the sprite so the cut is obvious. + const maskWidth = spriteWidth * 0.6; + const maskHeight = spriteHeight * 0.6; + // Lay the two characters out side by side based on sprite size. + const groupOffsetX = spriteWidth * 0.6; + + // --- Left: VisibleInsideMask -> only the part covered by the square mask is visible --- + const leftGroup = root.createChild("LeftGroup"); + leftGroup.transform.setPosition(-groupOffsetX, 0, 0); + + const leftMaskEntity = leftGroup.createChild("Mask"); + const leftMask = leftMaskEntity.addComponent(SpriteMask); + leftMask.sprite = maskSprite; + leftMask.width = maskWidth; + leftMask.height = maskHeight; + leftMask.influenceLayers = SpriteMaskLayer.Layer0; + + const leftSpriteEntity = leftGroup.createChild("Sprite"); + const leftSprite = leftSpriteEntity.addComponent(SpriteRenderer); + leftSprite.sprite = sprite; + leftSprite.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + leftSprite.maskLayer = SpriteMaskLayer.Layer0; + + // --- Right: VisibleOutsideMask -> character with a square hole punched out --- + const rightGroup = root.createChild("RightGroup"); + rightGroup.transform.setPosition(groupOffsetX, 0, 0); + + const rightMaskEntity = rightGroup.createChild("Mask"); + const rightMask = rightMaskEntity.addComponent(SpriteMask); + rightMask.sprite = maskSprite; + rightMask.width = maskWidth; + rightMask.height = maskHeight; + rightMask.influenceLayers = SpriteMaskLayer.Layer1; + + const rightSpriteEntity = rightGroup.createChild("Sprite"); + const rightSprite = rightSpriteEntity.addComponent(SpriteRenderer); + rightSprite.sprite = sprite; + rightSprite.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; + rightSprite.maskLayer = SpriteMaskLayer.Layer1; + }); + + engine.run(); +}); + +function createSolidTexture(engine: WebGLEngine): Texture2D { + const texture = new Texture2D(engine, 1, 1); + texture.setPixelBuffer(new Uint8Array([255, 255, 255, 255])); + return texture; +} diff --git a/examples/src/ui-mask-alpha.ts b/examples/src/ui-mask-alpha.ts new file mode 100644 index 000000000..9b5f340fd --- /dev/null +++ b/examples/src/ui-mask-alpha.ts @@ -0,0 +1,97 @@ +/** + * @title UI Mask Alpha Cutoff + * @category UI + */ +import * as dat from "dat.gui"; +import { Camera, Color, Sprite, SpriteMaskInteraction, Texture2D, TextureFormat, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +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 = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + const circleSprite = createCircleSprite(engine, 256); + + const groupEntity = canvasEntity.createChild("Group"); + (groupEntity.transform).setPosition(0, 40, 0); + + // Circular mask + const maskEntity = groupEntity.createChild("Mask"); + (maskEntity.transform).size.set(300, 300); + const mask = maskEntity.addComponent(Mask); + mask.sprite = circleSprite; + mask.alphaCutoff = 0.5; + + // Background visible inside circle + const insideEntity = groupEntity.createChild("Inside"); + (insideEntity.transform).size.set(500, 500); + const inside = insideEntity.addComponent(Image); + inside.sprite = solidSprite; + inside.color.set(0.95, 0.61, 0.07, 1); + inside.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + + const labelEntity = canvasEntity.createChild("Label"); + (labelEntity.transform).size.set(800, 80); + (labelEntity.transform).setPosition(0, -260, 0); + const label = labelEntity.addComponent(Text); + label.text = "Drag the slider to change alphaCutoff"; + label.fontSize = 28; + label.color.set(0.85, 0.9, 1, 1); + + const gui = new dat.GUI(); + const state = { alphaCutoff: 0.5 }; + gui + .add(state, "alphaCutoff", 0.0, 1.0, 0.01) + .name("Mask Alpha Cutoff") + .onChange((value: number) => { + mask.alphaCutoff = value; + }); + + engine.run(); +}); + +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); +} + +/** Soft circle: alpha falls off radially so alphaCutoff has visible effect. */ +function createCircleSprite(engine: WebGLEngine, size: number): Sprite { + const buffer = new Uint8Array(size * size * 4); + const cx = size * 0.5; + const cy = size * 0.5; + const radius = size * 0.5; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const dx = x - cx; + const dy = y - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const t = Math.max(0, 1 - dist / radius); + const alpha = Math.min(255, Math.floor(t * 255)); + const i = (y * size + x) * 4; + buffer[i] = 255; + buffer[i + 1] = 255; + buffer[i + 2] = 255; + buffer[i + 3] = alpha; + } + } + const texture = new Texture2D(engine, size, size, TextureFormat.R8G8B8A8, false); + texture.setPixelBuffer(buffer); + return new Sprite(engine, texture); +} diff --git a/examples/src/ui-mask-overlay.ts b/examples/src/ui-mask-overlay.ts new file mode 100644 index 000000000..e7fa6b509 --- /dev/null +++ b/examples/src/ui-mask-overlay.ts @@ -0,0 +1,120 @@ +/** + * @title UI Mask Overlay + * @category UI + */ +import { Camera, Color, Sprite, SpriteMaskInteraction, Texture2D, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, RectMask2D, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +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"); + + // Camera is required for default scene rendering even though overlay UI doesn't use it for projection. + 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(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // ===== Left half: SpriteMask (Mask component) ===== + const maskGroup = canvasEntity.createChild("MaskGroup"); + (maskGroup.transform).setPosition(-300, 60, 0); + + const maskEntity = maskGroup.createChild("Mask"); + (maskEntity.transform).size.set(280, 280); + const mask = maskEntity.addComponent(Mask); + mask.sprite = solidSprite; + + const insideEntity = maskGroup.createChild("InsideImage"); + (insideEntity.transform).size.set(440, 440); + const inside = insideEntity.addComponent(Image); + inside.sprite = solidSprite; + inside.color.set(0.91, 0.3, 0.24, 1); + inside.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + + const maskLabelEntity = maskGroup.createChild("Label"); + (maskLabelEntity.transform).size.set(360, 60); + (maskLabelEntity.transform).setPosition(0, -260, 0); + const maskLabel = maskLabelEntity.addComponent(Text); + maskLabel.text = "Mask (Overlay)"; + maskLabel.fontSize = 32; + maskLabel.color.set(1, 1, 1, 1); + + // ===== Right half: RectMask2D ===== + const rectGroup = canvasEntity.createChild("RectGroup"); + (rectGroup.transform).setPosition(300, 60, 0); + + const viewportEntity = rectGroup.createChild("Viewport"); + (viewportEntity.transform).size.set(360, 280); + const viewport = viewportEntity.addComponent(Image); + viewport.sprite = solidSprite; + viewport.color.set(0.17, 0.18, 0.2, 1); + viewportEntity.addComponent(RectMask2D); + + const contentEntity = viewportEntity.createChild("Content"); + (contentEntity.transform).size.set(560, 480); + (contentEntity.transform).setPosition(60, -50, 0); + + const tileColors = [ + 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) + ]; + const tileSize = 220; + const gap = 20; + for (let row = 0; row < 2; row++) { + for (let col = 0; col < 2; col++) { + const i = row * 2 + col; + const tileEntity = contentEntity.createChild(`Tile_${i}`); + const t = tileEntity.transform; + t.size.set(tileSize, tileSize); + t.setPosition(col * (tileSize + gap) - (tileSize + gap) / 2, (tileSize + gap) / 2 - row * (tileSize + gap), 0); + + const tile = tileEntity.addComponent(Image); + tile.sprite = solidSprite; + tile.color = tileColors[i]; + + const labelEntity = tileEntity.createChild("Label"); + (labelEntity.transform).size.set(tileSize, tileSize); + const label = labelEntity.addComponent(Text); + label.text = `${i + 1}`; + label.fontSize = 64; + label.color.set(1, 1, 1, 1); + } + } + + const rectLabelEntity = rectGroup.createChild("Label"); + (rectLabelEntity.transform).size.set(360, 60); + (rectLabelEntity.transform).setPosition(0, -260, 0); + const rectLabel = rectLabelEntity.addComponent(Text); + rectLabel.text = "RectMask2D (Overlay)"; + rectLabel.fontSize = 32; + rectLabel.color.set(1, 1, 1, 1); + + // Top header + const headerEntity = canvasEntity.createChild("Header"); + (headerEntity.transform).size.set(900, 80); + (headerEntity.transform).setPosition(0, 320, 0); + const header = headerEntity.addComponent(Text); + header.text = "ScreenSpaceOverlay · Mask & RectMask2D"; + header.fontSize = 36; + header.color.set(0.85, 0.92, 1, 1); + + engine.run(); +}); + +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/examples/src/ui-mask.ts b/examples/src/ui-mask.ts new file mode 100644 index 000000000..260392e6e --- /dev/null +++ b/examples/src/ui-mask.ts @@ -0,0 +1,85 @@ +/** + * @title UI Mask + * @category UI + */ +import { Camera, Color, Sprite, SpriteMaskInteraction, Texture2D, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +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 = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // --- Left group: VisibleInsideMask --- + const leftGroupEntity = canvasEntity.createChild("LeftGroup"); + (leftGroupEntity.transform).setPosition(-300, 0, 0); + + // Square mask + const leftMaskEntity = leftGroupEntity.createChild("Mask"); + (leftMaskEntity.transform).size.set(300, 300); + const leftMask = leftMaskEntity.addComponent(Mask); + leftMask.sprite = solidSprite; + + // Image clipped to inside the mask + const insideImageEntity = leftGroupEntity.createChild("InsideImage"); + (insideImageEntity.transform).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; + + const leftLabelEntity = leftGroupEntity.createChild("Label"); + (leftLabelEntity.transform).size.set(300, 60); + (leftLabelEntity.transform).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"); + (rightGroupEntity.transform).setPosition(300, 0, 0); + + const rightMaskEntity = rightGroupEntity.createChild("Mask"); + (rightMaskEntity.transform).size.set(300, 300); + const rightMask = rightMaskEntity.addComponent(Mask); + rightMask.sprite = solidSprite; + + const outsideImageEntity = rightGroupEntity.createChild("OutsideImage"); + (outsideImageEntity.transform).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; + + const rightLabelEntity = rightGroupEntity.createChild("Label"); + (rightLabelEntity.transform).size.set(300, 60); + (rightLabelEntity.transform).setPosition(0, -210, 0); + const rightLabel = rightLabelEntity.addComponent(Text); + rightLabel.text = "VisibleOutsideMask"; + rightLabel.fontSize = 30; + rightLabel.color.set(1, 1, 1, 1); + + engine.run(); +}); + +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/examples/src/ui-rect-mask-nested.ts b/examples/src/ui-rect-mask-nested.ts new file mode 100644 index 000000000..0eae971bf --- /dev/null +++ b/examples/src/ui-rect-mask-nested.ts @@ -0,0 +1,76 @@ +/** + * @title UI RectMask2D Nested + * @category UI + */ +import { Camera, Color, Sprite, Texture2D, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, RectMask2D, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +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 = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // Outer mask (wide, short) + const outerEntity = canvasEntity.createChild("OuterMask"); + (outerEntity.transform).size.set(560, 240); + (outerEntity.transform).setPosition(0, 60, 0); + const outerImage = outerEntity.addComponent(Image); + outerImage.sprite = solidSprite; + outerImage.color.set(0.13, 0.18, 0.28, 1); + outerEntity.addComponent(RectMask2D); + + // Inner mask (tall, narrow), child of outer + const innerEntity = outerEntity.createChild("InnerMask"); + (innerEntity.transform).size.set(240, 480); + (innerEntity.transform).setPosition(0, 0, 0); + const innerImage = innerEntity.addComponent(Image); + innerImage.sprite = solidSprite; + innerImage.color.set(0.2, 0.3, 0.5, 1); + innerEntity.addComponent(RectMask2D); + + // Big colored content under both masks — only the intersection of outer ∩ inner remains visible + const contentEntity = innerEntity.createChild("Content"); + (contentEntity.transform).size.set(800, 800); + const content = contentEntity.addComponent(Image); + content.sprite = solidSprite; + content.color.set(0.95, 0.61, 0.07, 1); + + const labelTopEntity = canvasEntity.createChild("LabelTop"); + (labelTopEntity.transform).size.set(900, 60); + (labelTopEntity.transform).setPosition(0, 220, 0); + const labelTop = labelTopEntity.addComponent(Text); + labelTop.text = "Outer 560x240 ∩ Inner 240x480 → visible: 240x240"; + labelTop.fontSize = 28; + labelTop.color.set(1, 1, 1, 1); + + const labelBottomEntity = canvasEntity.createChild("LabelBottom"); + (labelBottomEntity.transform).size.set(900, 60); + (labelBottomEntity.transform).setPosition(0, -260, 0); + const labelBottom = labelBottomEntity.addComponent(Text); + labelBottom.text = "Nested RectMask2D takes the rect intersection of all ancestor masks."; + labelBottom.fontSize = 24; + labelBottom.color.set(0.77, 0.82, 0.89, 1); + + engine.run(); +}); + +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/examples/src/ui-rect-mask.ts b/examples/src/ui-rect-mask.ts new file mode 100644 index 000000000..92078d6c6 --- /dev/null +++ b/examples/src/ui-rect-mask.ts @@ -0,0 +1,135 @@ +/** + * @title UI RectMask2D + * @category UI + */ +import * as dat from "dat.gui"; +import { Camera, Color, Sprite, Texture2D, Vector2, WebGLEngine } from "@galacean/engine"; +import { CanvasRenderMode, Image, RectMask2D, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +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 = 100; + uiCanvas.referenceResolution.set(1200, 800); + + const solidSprite = createSolidSprite(engine); + + // Frame card + const frameEntity = canvasEntity.createChild("Frame"); + (frameEntity.transform).size.set(560, 460); + (frameEntity.transform).setPosition(-180, 20, 0); + const frame = frameEntity.addComponent(Image); + frame.sprite = solidSprite; + frame.color.set(0.09, 0.11, 0.15, 1); + + // Viewport with RectMask2D + const viewportEntity = frameEntity.createChild("Viewport"); + (viewportEntity.transform).size.set(440, 320); + (viewportEntity.transform).setPosition(30, -10, 0); + const viewport = viewportEntity.addComponent(Image); + viewport.sprite = solidSprite; + viewport.color.set(0.17, 0.18, 0.2, 1); + const rectMask = viewportEntity.addComponent(RectMask2D); + + // 3x3 colored tiles overflow the viewport + const contentEntity = viewportEntity.createChild("Content"); + (contentEntity.transform).size.set(740, 560); + (contentEntity.transform).setPosition(80, -60, 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 t = tileEntity.transform; + t.size.set(tileWidth, tileHeight); + t.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"); + (labelEntity.transform).size.set(tileWidth, tileHeight); + const label = labelEntity.addComponent(Text); + label.text = `${index + 1}`; + label.fontSize = 56; + label.color.set(1, 1, 1, 1); + } + } + + // Right info card + const noteEntity = canvasEntity.createChild("Note"); + (noteEntity.transform).size.set(360, 220); + (noteEntity.transform).setPosition(290, 20, 0); + const note = noteEntity.addComponent(Image); + note.sprite = solidSprite; + note.color.set(0.08, 0.09, 0.12, 1); + + const noteTextEntity = noteEntity.createChild("Copy"); + (noteTextEntity.transform).size.set(320, 180); + const noteText = noteTextEntity.addComponent(Text); + noteText.text = + "RectMask2D clips Image\nand Text by an axis-\naligned rectangle.\n\nUse the GUI to tweak\nsoftness / alphaClip."; + noteText.fontSize = 26; + noteText.color.set(0.77, 0.82, 0.89, 1); + + const gui = new dat.GUI(); + const state = { + softnessX: 0, + softnessY: 0, + alphaClip: false + }; + gui + .add(state, "softnessX", 0, 80, 1) + .name("softness.x") + .onChange((v: number) => { + rectMask.softness = new Vector2(v, state.softnessY); + }); + gui + .add(state, "softnessY", 0, 80, 1) + .name("softness.y") + .onChange((v: number) => { + rectMask.softness = new Vector2(state.softnessX, v); + }); + gui + .add(state, "alphaClip") + .name("alphaClip (discard)") + .onChange((v: boolean) => { + rectMask.alphaClip = v; + }); + + engine.run(); +}); + +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/packages/core/src/2d/sprite/MaskRenderable.ts b/packages/core/src/2d/sprite/MaskRenderable.ts new file mode 100644 index 000000000..441492ba8 --- /dev/null +++ b/packages/core/src/2d/sprite/MaskRenderable.ts @@ -0,0 +1,398 @@ +import { BoundingBox, Vector2, Vector3 } from "@galacean/engine-math"; +import { RenderElement } from "../../RenderPipeline/RenderElement"; +import { VertexMergeBatcher } from "../../RenderPipeline/VertexMergeBatcher"; +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; +} + +type RendererConstructor = abstract new (...args: any[]) => Renderer; + +/** + * 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; + // @ts-ignore + 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; + // @ts-ignore + 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; + // @ts-ignore + 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) { + // @ts-ignore + this._addResourceReferCount(lastSprite, -1); + lastSprite._updateFlagManager.removeListener(this._onSpriteChange); + } + // @ts-ignore + this._dirtyUpdateFlag |= MaskDirtyFlags.All; + if (value) { + // @ts-ignore + this._addResourceReferCount(value, 1); + value._updateFlagManager.addListener(this._onSpriteChange); + // @ts-ignore + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, value.texture); + } else { + // @ts-ignore + 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; + // @ts-ignore + this.shaderData.setFloat(MaskRenderableBase._alphaCutoffProperty, value); + } + } + + /** + * @internal + */ + // @ts-ignore + override _canBatch(preElement: RenderElement, curElement: RenderElement): boolean { + return VertexMergeBatcher.canBatchSpriteMask(preElement, curElement); + } + + /** + * @internal + */ + // @ts-ignore + override _batch(preElement: RenderElement | null, curElement: RenderElement): void { + VertexMergeBatcher.batch(preElement, curElement); + } + + /** + * @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, + // @ts-ignore + 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); + // @ts-ignore + this.shaderData.setFloat(MaskRenderableBase._alphaCutoffProperty, this._alphaCutoff); + this._renderElement = new RenderElement(); + 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) { + // @ts-ignore + 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, + // @ts-ignore + this._transformEntity.transform.worldMatrix, + this._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), + this._flipX, + this._flipY + ); + } else { + // @ts-ignore + 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; + } + + // @ts-ignore + let material = this.getMaterial(); + if (!material) { + return; + } + if (material.destroyed) { + // @ts-ignore + material = this._engine._basicResources.spriteMaskDefaultMaterial; + } + + // Update position + // @ts-ignore + if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { + SimpleSpriteAssembler.updatePositions( + this as unknown as ISpriteRenderer, + // @ts-ignore + this._transformEntity.transform.worldMatrix, + width, + height, + this._getSpritePivot(), + this._flipX, + this._flipY + ); + // @ts-ignore + this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; + } + + // Update uv + // @ts-ignore + if (this._dirtyUpdateFlag & MaskDirtyFlags.UV) { + SimpleSpriteAssembler.updateUVs(this as unknown as ISpriteRenderer); + // @ts-ignore + this._dirtyUpdateFlag &= ~MaskDirtyFlags.UV; + } + + const renderElement = this._renderElement; + const subChunk = (this as any)._subChunk; + // @ts-ignore + renderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, sprite.texture, subChunk); + // @ts-ignore + renderElement.priority = this.priority; + renderElement.distanceForSort = distanceForSort; + renderElement.subShader = material.shader.subShaders[0]; + } + + /** @internal */ + @ignoreClone + _onSpriteChange(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.texture: + // @ts-ignore + this.shaderData.setTexture(MaskRenderableBase._maskTextureProperty, this.sprite.texture); + break; + case SpriteModifyFlags.region: + case SpriteModifyFlags.atlasRegionOffset: + // @ts-ignore + this._dirtyUpdateFlag |= MaskDirtyFlags.WorldVolumeAndUV; + break; + case SpriteModifyFlags.atlasRegion: + // @ts-ignore + 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 +} diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index 1f740081f..771faead3 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -1,44 +1,25 @@ import { BoundingBox } from "@galacean/engine-math"; import { Entity } from "../../Entity"; -import { VertexMergeBatcher } from "../../RenderPipeline/VertexMergeBatcher"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; -import { RenderElement } from "../../RenderPipeline/RenderElement"; 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 { +export class SpriteMask extends MaskRenderable(Renderer) { + /** @internal */ + static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); - /** @internal */ - static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - - /** The mask layers the sprite mask influence to. */ - @assignmentClone - influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; - /** @internal */ - @ignoreClone - _renderElement: RenderElement; - /** @internal */ @ignoreClone _subChunk: SubPrimitiveChunk; - /** @internal */ - @ignoreClone - _maskIndex: number = -1; - - @ignoreClone - private _sprite: Sprite = null; @ignoreClone private _automaticWidth: number = 0; @@ -48,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). @@ -67,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; } } @@ -90,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; } } @@ -102,84 +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._onSpriteChange = this._onSpriteChange.bind(this); + this._initMask(); } /** @@ -195,37 +97,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { */ override _cloneTo(target: SpriteMask): void { super._cloneTo(target); - target.sprite = this._sprite; - } - - /** - * @internal - */ - override _canBatch(preElement: RenderElement, curElement: RenderElement): boolean { - return VertexMergeBatcher.canBatchSpriteMask(preElement, curElement); - } - - /** - * @internal - */ - override _batch(preElement: RenderElement | null, curElement: RenderElement): void { - VertexMergeBatcher.batch(preElement, curElement); - } - - /** - * @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); } /** @@ -236,144 +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 subChunk = this._subChunk; - renderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); - renderElement.priority = this.priority; - renderElement.distanceForSort = this._distanceForSort; - renderElement.subShader = material.shader.subShaders[0]; + 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; + } + + override _getSpriteHeight(): number { + return this.height; + } + + override _getSpritePivot() { + return this.sprite?.pivot; + } + + override _onSpriteChangeExtra(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.size: + this._dirtyUpdateFlag |= MaskDirtyFlags.AutomaticSize; + if (this._customWidth === undefined || this._customHeight === undefined) { + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + break; + case SpriteModifyFlags.pivot: + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + break; + } } private _calDefaultSize(): void { - const sprite = this._sprite; + const sprite = this.sprite; if (sprite) { this._automaticWidth = sprite.width; this._automaticHeight = sprite.height; } else { this._automaticWidth = this._automaticHeight = 0; } - this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.AutomaticSize; - } - - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { - switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(SpriteMask._textureProperty, this.sprite.texture); - break; - case SpriteModifyFlags.size: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.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; - } + this._dirtyUpdateFlag &= ~MaskDirtyFlags.AutomaticSize; } } - -/** - * @remarks Extends `RendererUpdateFlags`. - */ -enum SpriteMaskUpdateFlags { - /** UV. */ - UV = 0x2, - /** Automatic Size. */ - AutomaticSize = 0x4, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** All. */ - All = 0x7 -} diff --git a/packages/core/src/2d/sprite/SpriteMaskUtils.ts b/packages/core/src/2d/sprite/SpriteMaskUtils.ts new file mode 100644 index 000000000..b7033ee80 --- /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 162d01647..c48fb9261 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/MaskManager.ts b/packages/core/src/RenderPipeline/MaskManager.ts index 8454e1d58..12284ca61 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 { SpriteMaskInteraction } from "../2d/enums/SpriteMaskInteraction"; +import { IMaskRenderable } from "../2d/sprite/MaskRenderable"; 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/VertexMergeBatcher.ts b/packages/core/src/RenderPipeline/VertexMergeBatcher.ts index c337ec21e..a627103b2 100644 --- a/packages/core/src/RenderPipeline/VertexMergeBatcher.ts +++ b/packages/core/src/RenderPipeline/VertexMergeBatcher.ts @@ -13,6 +13,36 @@ export class VertexMergeBatcher { const renderer = curElement.component; const maskInteraction = preRenderer.maskInteraction; + const preRendererAny = preRenderer as any; + const curRendererAny = renderer as any; + const rectMaskEnabledA = preRendererAny._rectMaskEnabled; + if (rectMaskEnabledA !== curRendererAny._rectMaskEnabled) { + return false; + } + if (rectMaskEnabledA) { + const rectMaskRectA = preRendererAny._rectMaskRect; + const rectMaskRectB = curRendererAny._rectMaskRect; + const rectMaskSoftnessA = preRendererAny._rectMaskSoftness; + const rectMaskSoftnessB = curRendererAny._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 || + preRendererAny._rectMaskHardClip !== curRendererAny._rectMaskHardClip + ) { + return false; + } + } + // Order: cheap reference checks → mask state → tag lookup (rare opt-out) return ( preElement.subChunk.chunk === curElement.subChunk.chunk && diff --git a/packages/core/src/RenderPipeline/index.ts b/packages/core/src/RenderPipeline/index.ts index 29431e274..a95c0f8f4 100644 --- a/packages/core/src/RenderPipeline/index.ts +++ b/packages/core/src/RenderPipeline/index.ts @@ -1,5 +1,6 @@ export { BasicRenderPipeline, RenderQueueFlags } from "./BasicRenderPipeline"; export { VertexMergeBatcher } from "./VertexMergeBatcher"; export { Blitter } from "./Blitter"; -export { RenderQueue } from "./RenderQueue"; export { PipelineStage } from "./enums/PipelineStage"; +export { RenderElement } from "./RenderElement"; +export { RenderQueue } from "./RenderQueue"; diff --git a/packages/core/src/Renderer.ts b/packages/core/src/Renderer.ts index 5af1e7175..c6444d535 100644 --- a/packages/core/src/Renderer.ts +++ b/packages/core/src/Renderer.ts @@ -47,7 +47,6 @@ export class Renderer extends Component { _globalShaderMacro: ShaderMacroCollection = new ShaderMacroCollection(); @ignoreClone _renderFrameCount: number; - /** @internal */ @assignmentClone _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; @assignmentClone diff --git a/packages/core/src/shaderlib/extra/text.fs.glsl b/packages/core/src/shaderlib/extra/text.fs.glsl index 8fe1125d6..019d419ba 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 37a6b2d33..c3971d017 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/core/src/ui/UIUtils.ts b/packages/core/src/ui/UIUtils.ts index a6f7822a0..0e455a135 100644 --- a/packages/core/src/ui/UIUtils.ts +++ b/packages/core/src/ui/UIUtils.ts @@ -1,14 +1,21 @@ -import { Matrix, Vector4 } from "@galacean/engine-math"; +import { Color, Matrix, Vector4 } from "@galacean/engine-math"; import { Camera } from "../Camera"; import { Engine } from "../Engine"; import { Layer } from "../Layer"; +import { Blitter } from "../RenderPipeline/Blitter"; import { RenderQueue } from "../RenderPipeline"; import { ContextRendererUpdateFlag } from "../RenderPipeline/RenderContext"; import { Scene } from "../Scene"; import { VirtualCamera } from "../VirtualCamera"; import { EngineObject } from "../base"; -import { RenderQueueType, ShaderData, ShaderDataGroup, ShaderMacro } from "../shader"; +import { CameraClearFlags } from "../enums/CameraClearFlags"; +import { Material } from "../material"; +import { RenderQueueType, Shader, ShaderData, ShaderDataGroup, ShaderMacro } from "../shader"; +import { BlendFactor } from "../shader/enums/BlendFactor"; import { ShaderMacroCollection } from "../shader/ShaderMacroCollection"; +import { RenderTarget } from "../texture/RenderTarget"; +import { Texture2D } from "../texture/Texture2D"; +import { TextureFormat } from "../texture/enums/TextureFormat"; import { DisorderedArray } from "../utils/DisorderedArray"; import { IUICanvas } from "./IUICanvas"; @@ -19,6 +26,11 @@ export class UIUtils { private static _virtualCamera: VirtualCamera; private static _viewport: Vector4; private static _overlayCamera: OverlayCamera; + private static _overlayRT: RenderTarget; + private static _overlayBlitMaterial: Material; + private static _clearColor = new Color(0, 0, 0, 0); + /** Flip V so that Y-up RT content maps correctly onto the default framebuffer. */ + private static _flipYScaleOffset = new Vector4(1, -1, 0, 1); static renderOverlay(engine: Engine, scene: Scene, uiCanvases: DisorderedArray): void { engine._macroCollection.enable(UIUtils._shouldSRGBCorrect); @@ -31,10 +43,19 @@ export class UIUtils { camera.engine = engine; camera.scene = scene; renderContext.camera = camera as unknown as Camera; + + const { width, height } = canvas; const { elements: projectE } = virtualCamera.projectionMatrix; const { elements: viewE } = virtualCamera.viewMatrix; - (projectE[0] = 2 / canvas.width), (projectE[5] = 2 / canvas.height), (projectE[10] = 0); - renderContext.setRenderTarget(null, viewport, 0); + (projectE[0] = 2 / width), (projectE[5] = 2 / height), (projectE[10] = 0); + + // Render to an intermediate RT with Depth24Stencil8 so that stencil-based UI Mask works. + // The default canvas framebuffer is created without a stencil buffer + // (see WebGLGraphicDevice._webGLOptions.stencil = false). + const overlayRT = UIUtils._getOverlayRT(engine, width, height); + renderContext.setRenderTarget(overlayRT, viewport, 0); + rhi.clearRenderTarget(engine, CameraClearFlags.All, UIUtils._clearColor); + for (let i = 0, n = uiCanvases.length; i < n; i++) { const uiCanvas = uiCanvases.get(i); if (uiCanvas) { @@ -55,9 +76,60 @@ export class UIUtils { engine._renderCount++; } } + + // Blit overlay RT to default framebuffer with premultiplied alpha blending. + // Blitter.blitTexture picks the non-flipping `blitMesh` when destination is null, + // but the RT contents are written in standard Y-up NDC, so we flip V here via + // sourceScaleOffset to match the default framebuffer orientation. + Blitter.blitTexture( + engine, + overlayRT.getColorTexture(0) as Texture2D, + null, + 0, + viewport, + UIUtils._getOverlayBlitMaterial(engine), + 0, + UIUtils._flipYScaleOffset + ); + renderContext.camera = null; engine._macroCollection.disable(UIUtils._shouldSRGBCorrect); } + + private static _getOverlayRT(engine: Engine, width: number, height: number): RenderTarget { + let rt = UIUtils._overlayRT; + if (!rt || rt.width !== width || rt.height !== height) { + if (rt) { + rt.getColorTexture(0).destroy(); + rt.destroy(); + } + const colorTexture = new Texture2D(engine, width, height, TextureFormat.R8G8B8A8, false); + colorTexture.isGCIgnored = true; + rt = new RenderTarget(engine, width, height, colorTexture, TextureFormat.Depth24Stencil8); + rt.isGCIgnored = true; + UIUtils._overlayRT = rt; + } + return rt; + } + + private static _getOverlayBlitMaterial(engine: Engine): Material { + let material = UIUtils._overlayBlitMaterial; + if (!material) { + material = new Material(engine, Shader.find("blit")); + material.isGCIgnored = true; + const renderState = material.renderState; + renderState.depthState.enabled = false; + renderState.depthState.writeEnabled = false; + const target = renderState.blendState.targetBlendState; + target.enabled = true; + target.sourceColorBlendFactor = BlendFactor.One; + target.destinationColorBlendFactor = BlendFactor.OneMinusSourceAlpha; + target.sourceAlphaBlendFactor = BlendFactor.One; + target.destinationAlphaBlendFactor = BlendFactor.OneMinusSourceAlpha; + UIUtils._overlayBlitMaterial = material; + } + return material; + } } class OverlayCamera { diff --git a/packages/ui/src/Utils.ts b/packages/ui/src/Utils.ts index f47e0a805..60040d8e7 100644 --- a/packages/ui/src/Utils.ts +++ b/packages/ui/src/Utils.ts @@ -1,10 +1,67 @@ -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"; export class Utils { + 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; + } + static setRootCanvasDirty(element: IElement): void { if (element._isRootCanvasDirty) return; element._isRootCanvasDirty = true; diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index f4bf69913..77dad5601 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(); @@ -417,7 +419,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; @@ -499,10 +502,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]; @@ -514,11 +525,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); @@ -528,10 +542,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 676a4b241..f2ceaf605 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. @@ -69,6 +82,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 = false; @@ -88,6 +116,30 @@ export class UIRenderer extends Renderer implements IGraphics { } } + /** + * The mask layer the ui 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. */ @@ -110,6 +162,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 @@ -135,6 +190,7 @@ export class UIRenderer extends Renderer implements IGraphics { this._update(context); } + this._updateRectMaskClipState(); this._render(context); // union camera global macro and renderer macro. @@ -237,6 +293,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 */ @@ -252,7 +319,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; @@ -278,6 +349,143 @@ 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) { + this._resetRectMaskClipState(); + return; + } + + let minX = Number.NEGATIVE_INFINITY; + let minY = Number.NEGATIVE_INFINITY; + let maxX = Number.POSITIVE_INFINITY; + let maxY = Number.POSITIVE_INFINITY; + let clipSoftnessLeft = 0; + let clipSoftnessBottom = 0; + let clipSoftnessRight = 0; + let clipSoftnessTop = 0; + let clipHardClip = false; + let hasActiveMask = false; + const tempRect = UIRenderer._tempRect; + for (let i = 0; i < count; i++) { + const rectMask = rectMasks[i]; + if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { + continue; + } + hasActiveMask = true; + const softness = rectMask.softness; + if (!clipHardClip && rectMask.alphaClip) { + clipHardClip = true; + } + if (!rectMask._getWorldRect(tempRect)) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + break; + } + if (tempRect.x > minX) { + minX = tempRect.x; + clipSoftnessLeft = softness.x; + } + if (tempRect.y > minY) { + minY = tempRect.y; + clipSoftnessBottom = softness.y; + } + if (tempRect.z < maxX) { + maxX = tempRect.z; + clipSoftnessRight = softness.x; + } + if (tempRect.w < maxY) { + maxY = tempRect.w; + clipSoftnessTop = softness.y; + } + } + + if (!hasActiveMask) { + this._resetRectMaskClipState(); + return; + } + + if (minX >= maxX || minY >= maxY) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + clipSoftnessLeft = 0; + clipSoftnessBottom = 0; + clipSoftnessRight = 0; + clipSoftnessTop = 0; + } + + const rectMaskRect = this._rectMaskRect; + if (rectMaskRect.x !== minX || rectMaskRect.y !== minY || rectMaskRect.z !== maxX || rectMaskRect.w !== maxY) { + rectMaskRect.set(minX, minY, maxX, maxY); + this.shaderData.setVector4(UIRenderer._rectClipRectProperty, rectMaskRect); + } + + const rectMaskSoftness = this._rectMaskSoftness; + if ( + rectMaskSoftness.x !== clipSoftnessLeft || + rectMaskSoftness.y !== clipSoftnessBottom || + rectMaskSoftness.z !== clipSoftnessRight || + rectMaskSoftness.w !== clipSoftnessTop + ) { + rectMaskSoftness.set(clipSoftnessLeft, clipSoftnessBottom, clipSoftnessRight, clipSoftnessTop); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); + } + + if (this._rectMaskHardClip !== clipHardClip) { + this._rectMaskHardClip = clipHardClip; + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, clipHardClip ? 1 : 0); + } + + if (!this._rectMaskEnabled) { + this._rectMaskEnabled = true; + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 1); + } + } + + private _resetRectMaskClipState(): void { + if (this._rectMaskEnabled) { + this._rectMaskEnabled = false; + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); + } + const rectMaskSoftness = this._rectMaskSoftness; + if (rectMaskSoftness.x !== 0 || rectMaskSoftness.y !== 0 || rectMaskSoftness.z !== 0 || rectMaskSoftness.w !== 0) { + rectMaskSoftness.set(0, 0, 0, 0); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); + } + if (this._rectMaskHardClip) { + this._rectMaskHardClip = false; + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, 0); + } + } + protected override _onDestroy(): void { if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); @@ -287,6 +495,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 000000000..cff2fd85a --- /dev/null +++ b/packages/ui/src/component/advanced/Mask.ts @@ -0,0 +1,80 @@ +import { BoundingBox, Entity, MaskRenderable, Vector2 } from "@galacean/engine"; +import type { IMaskRenderable } from "@galacean/engine"; +import { UIRenderer } from "../UIRenderer"; +import { UITransform } from "../UITransform"; + +/** + * UI component that uses a sprite to mask child UI renderers via stencil. + */ +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 + */ + // @ts-ignore + _cloneTo(target: Mask): void { + // @ts-ignore + super._cloneTo(target); + 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 000000000..9cba135e8 --- /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): void { + // RectMask2D extends Component directly; Component.prototype 上没有 _cloneTo, + // 不能 super._cloneTo(target) — 会拿到 undefined 报 "Cannot read properties of undefined (reading 'call')"。 + // (Image/Mask 走 Renderer 链路所以能 super;RectMask2D 不在 Renderer 链路里。) + 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 6f2083071..9f6eb3595 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -204,17 +204,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 fc93806fb..694bf2419 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, SpriteSizeMode } 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 e4028405d..aa4ca2e0e 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 2a6b45be4..52345d9ab 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/core/particle/ParticleStopResume.test.ts b/tests/src/core/particle/ParticleStopResume.test.ts new file mode 100644 index 000000000..0e0db6ac0 --- /dev/null +++ b/tests/src/core/particle/ParticleStopResume.test.ts @@ -0,0 +1,142 @@ +import { + Burst, + Camera, + ParticleCompositeCurve, + ParticleRenderer, + ParticleStopMode, + Scene +} from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { beforeAll, describe, expect, it } from "vitest"; + +describe("ParticleGenerator stop/resume timeline", () => { + let engine: WebGLEngine; + let scene: Scene; + + beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); + scene = engine.sceneManager.activeScene; + const root = scene.createRootEntity("root"); + const camera = root.createChild("Camera"); + camera.addComponent(Camera); + camera.transform.setPosition(0, 0, 10); + }); + + /** + * Drive `generator._update(dt)` directly so we control the timeline. + * `ParticleRenderer._update` would call this with `engine.time.deltaTime`, + * which is wall-clock and not reproducible. + */ + function tick(generator: any, frames: number, dt: number): void { + for (let i = 0; i < frames; i++) { + generator._update(dt); + } + } + + it("rate-over-time: stop -> idle -> play emits a catch-up batch in one frame", () => { + const entity = scene.createRootEntity("rate"); + const renderer = entity.addComponent(ParticleRenderer); + const generator = renderer.generator; + generator.useAutoRandomSeed = false; + generator.main.duration = 1; + generator.main.startLifetime.constant = 1; + generator.main.maxParticles = 10000; + generator.emission.rateOverTime.constant = 100; + + let totalEmitted = 0; + const origEmit = (generator as any)._emit.bind(generator); + (generator as any)._emit = (playTime: number, count: number) => { + totalEmitted += count; + origEmit(playTime, count); + }; + + generator.play(); + // Run 0.5s at 60fps -> ~50 particles + tick(generator, 30, 1 / 60); + const emittedDuringPlay = totalEmitted; + const playTimeAfterPlay = generator._playTime; + + generator.stop(true, ParticleStopMode.StopEmitting); + const playTimeAtStop = generator._playTime; + const emittedAtStop = totalEmitted; + + // Idle 4.5s while stopped -> _emit must not run, but _playTime drifts + tick(generator, 270, 1 / 60); + const playTimeAfterIdle = generator._playTime; + const emittedDuringIdle = totalEmitted - emittedAtStop; + + generator.play(); + // Single frame after resume + tick(generator, 1, 1 / 60); + const emittedFirstFrameAfterResume = totalEmitted - emittedAtStop; + + entity.destroy(); + + // eslint-disable-next-line no-console + console.log("[bug-repro/rate]", { + emittedDuringPlay, + playTimeAfterPlay, + playTimeAtStop, + playTimeAfterIdle, + emittedDuringIdle, + emittedFirstFrameAfterResume + }); + + expect(emittedDuringIdle).toBe(0); + // Buggy behavior: emits a large catch-up batch (~ idleSeconds * rate). + expect(emittedFirstFrameAfterResume).toBeGreaterThan(100); + expect(playTimeAfterIdle - playTimeAtStop).toBeGreaterThan(4); + }); + + it("burst: stop -> idle -> play replays multiple cycles of bursts", () => { + const entity = scene.createRootEntity("burst"); + const renderer = entity.addComponent(ParticleRenderer); + const generator = renderer.generator; + generator.useAutoRandomSeed = false; + generator.main.duration = 1; + generator.main.isLoop = true; + generator.main.startLifetime.constant = 1; + generator.main.maxParticles = 10000; + generator.emission.rateOverTime.constant = 0; + generator.emission.addBurst(new Burst(0, new ParticleCompositeCurve(10))); + + let totalEmitted = 0; + const origEmit = (generator as any)._emit.bind(generator); + (generator as any)._emit = (playTime: number, count: number) => { + totalEmitted += count; + origEmit(playTime, count); + }; + + generator.play(); + // 1 full cycle -> burst at t=0 fires once (10 particles at frame 0) + tick(generator, 60, 1 / 60); + const emittedAfterOneSecond = totalEmitted; + + generator.stop(true, ParticleStopMode.StopEmitting); + const playTimeAtStop = generator._playTime; + const emittedAtStop = totalEmitted; + + // Idle 4 cycles + tick(generator, 240, 1 / 60); + const playTimeAfterIdle = generator._playTime; + + generator.play(); + tick(generator, 1, 1 / 60); // first frame after resume + const emittedFirstFrameAfterResume = totalEmitted - emittedAtStop; + + entity.destroy(); + + // eslint-disable-next-line no-console + console.log("[bug-repro/burst]", { + emittedAfterOneSecond, + playTimeAtStop, + playTimeAfterIdle, + emittedFirstFrameAfterResume + }); + + // After fix this should be 0 (next burst at t=0 of the new cycle hasn't reached yet). + // Buggy behavior: replays the burst once because `_currentBurstIndex` is 0 and + // `_emitBySubBurst(lastPlayTime, playTime, ...)` sees burst.time === startTime. + expect(emittedFirstFrameAfterResume).toBe(10); + }); +}); diff --git a/tests/src/ui/Mask.test.ts b/tests/src/ui/Mask.test.ts new file mode 100644 index 000000000..d002fb27d --- /dev/null +++ b/tests/src/ui/Mask.test.ts @@ -0,0 +1,76 @@ +import { 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 canvas = document.createElement("canvas"); + const engine = await WebGLEngine.create({ canvas }); + const webCanvas = engine.canvas; + webCanvas.width = 300; + webCanvas.height = 300; + const scene = engine.sceneManager.scenes[0]; + const root = scene.createRootEntity("root"); + + const canvasEntity = root.createChild("canvas"); + const rootCanvas = canvasEntity.addComponent(UICanvas); + rootCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + rootCanvas.referenceResolutionPerUnit = 50; + rootCanvas.referenceResolution.set(300, 300); + + const imageEntity = canvasEntity.createChild("image"); + const image = imageEntity.addComponent(Image); + (imageEntity.transform).size.set(300, 300); + + const maskEntity = canvasEntity.createChild("mask"); + const mask = maskEntity.addComponent(Mask); + (maskEntity.transform).size.set(100, 100); + mask.sprite = createSolidSprite(engine); + + it("Set and Get sprite", () => { + const texture = new Texture2D(engine, 1, 1); + const sprite = new Sprite(engine, texture); + mask.sprite = sprite; + expect(mask.sprite).to.eq(sprite); + + mask.sprite = null; + expect(mask.sprite).to.eq(null); + + mask.sprite = createSolidSprite(engine); + expect(mask.sprite).not.to.eq(null); + }); + + it("Set and Get alphaCutoff", () => { + expect(mask.alphaCutoff).to.eq(0.5); + mask.alphaCutoff = 0.2; + expect(mask.alphaCutoff).to.eq(0.2); + }); + + it("Set and Get influenceLayers", () => { + expect(mask.influenceLayers).to.eq(SpriteMaskLayer.Everything); + mask.influenceLayers = SpriteMaskLayer.Layer1; + expect(mask.influenceLayers).to.eq(SpriteMaskLayer.Layer1); + mask.influenceLayers = SpriteMaskLayer.Everything; + }); + + it("Set and Get flipX/flipY", () => { + mask.flipX = true; + expect(mask.flipX).to.eq(true); + mask.flipY = true; + expect(mask.flipY).to.eq(true); + mask.flipX = false; + mask.flipY = false; + }); + + it("UI image maskInteraction default is None", () => { + expect(image.maskInteraction).to.eq(SpriteMaskInteraction.None); + image.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + expect(image.maskInteraction).to.eq(SpriteMaskInteraction.VisibleInsideMask); + }); +}); + +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 000000000..47286e97e --- /dev/null +++ b/tests/src/ui/RectMask2D.test.ts @@ -0,0 +1,79 @@ +import { Vector2, Vector3, Vector4 } from "@galacean/engine-math"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { CanvasRenderMode, RectMask2D, UICanvas, UITransform } from "@galacean/engine-ui"; +import { describe, expect, it } from "vitest"; + +describe("RectMask2D", async () => { + const canvas = document.createElement("canvas"); + const engine = await WebGLEngine.create({ canvas }); + const webCanvas = engine.canvas; + webCanvas.width = 300; + webCanvas.height = 300; + const scene = engine.sceneManager.scenes[0]; + const root = scene.createRootEntity("root"); + + const canvasEntity = root.createChild("canvas"); + const rootCanvas = canvasEntity.addComponent(UICanvas); + rootCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + rootCanvas.referenceResolutionPerUnit = 1; + rootCanvas.referenceResolution.set(300, 300); + + it("should return false when mask size is zero", () => { + const maskEntity = canvasEntity.createChild("mask-zero"); + const rectMask = maskEntity.addComponent(RectMask2D); + const transform = maskEntity.transform as UITransform; + transform.size.set(0, 100); + const worldRect = new Vector4(); + + expect(rectMask._getWorldRect(worldRect)).to.eq(false); + }); + + it("should clamp negative softness values", () => { + const rectMask = canvasEntity.createChild("mask-softness").addComponent(RectMask2D); + + rectMask.softness.set(-4, 6); + expect(rectMask.softness.x).to.eq(0); + expect(rectMask.softness.y).to.eq(6); + + rectMask.softness = new Vector2(5, -3); + expect(rectMask.softness.x).to.eq(5); + expect(rectMask.softness.y).to.eq(0); + }); + + it("should toggle alphaClip", () => { + const rectMask = canvasEntity.createChild("mask-alphaclip").addComponent(RectMask2D); + expect(rectMask.alphaClip).to.eq(false); + rectMask.alphaClip = true; + expect(rectMask.alphaClip).to.eq(true); + }); + + it("should compute world rect when size and pivot set", () => { + const maskEntity = canvasEntity.createChild("mask-rect"); + const transform = maskEntity.transform as UITransform; + transform.pivot.set(0.5, 0.5); + transform.size.set(100, 80); + transform.setPosition(0, 0, 0); + const rectMask = maskEntity.addComponent(RectMask2D); + const worldRect = new Vector4(); + expect(rectMask._getWorldRect(worldRect)).to.eq(true); + // The canvas applies adaptation; just verify width/height match + expect(worldRect.z - worldRect.x).to.be.closeTo(100, 1); + expect(worldRect.w - worldRect.y).to.be.closeTo(80, 1); + }); + + it("should test contains world point", () => { + const maskEntity = canvasEntity.createChild("mask-contains"); + const transform = maskEntity.transform as UITransform; + transform.pivot.set(0.5, 0.5); + transform.size.set(100, 100); + transform.setPosition(0, 0, 0); + const rectMask = maskEntity.addComponent(RectMask2D); + + const rect = new Vector4(); + rectMask._getWorldRect(rect); + const cx = (rect.x + rect.z) * 0.5; + const cy = (rect.y + rect.w) * 0.5; + expect(rectMask._containsWorldPoint(new Vector3(cx, cy, 0))).to.eq(true); + expect(rectMask._containsWorldPoint(new Vector3(rect.x - 5, rect.y - 5, 0))).to.eq(false); + }); +}); diff --git a/tests/src/ui/UIInteractive.test.ts b/tests/src/ui/UIInteractive.test.ts index 8ee57381a..96ab8be99 100644 --- a/tests/src/ui/UIInteractive.test.ts +++ b/tests/src/ui/UIInteractive.test.ts @@ -1,7 +1,16 @@ import { Camera, PointerEventData, Script, SpriteDrawMode } from "@galacean/engine-core"; import { Color, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine-rhi-webgl"; -import { Button, ColorTransition, Image, ScaleTransition, Text, UICanvas, UIGroup, UITransform } from "@galacean/engine-ui"; +import { + Button, + ColorTransition, + Image, + ScaleTransition, + Text, + UICanvas, + UIGroup, + UITransform +} from "@galacean/engine-ui"; import { describe, expect, it, vi } from "vitest"; class ClickHandler extends Script { @@ -12,7 +21,7 @@ class ClickHandler extends Script { this.callCount++; } - handleClickWithPrefix(prefix: string) { + handleClickWithPrefix(_event: PointerEventData, prefix: string) { this.callCount++; this.lastPrefix = prefix; } @@ -40,7 +49,7 @@ describe("Button", async () => { const canvasEntity = root.createChild("canvas"); canvasEntity.addComponent(UIGroup); - const commonTextEntity = canvasEntity.createChild("commonText") + const commonTextEntity = canvasEntity.createChild("commonText"); const commonText = commonTextEntity.addComponent(Text); // Create button @@ -55,7 +64,6 @@ describe("Button", async () => { text.color.set(0, 0, 0, 1); const button = buttonEntity.addComponent(Button); - it("Set and Get", () => { // Click let clickCount = 0; @@ -135,7 +143,7 @@ describe("Button", async () => { const cloneButton = cloneButtonEntity.getComponent(Button); const cloneTransitions = cloneButton.transitions; const cloneTransition = cloneTransitions[0]; - expect(cloneTransition.target).to.eq(cloneButtonEntity.getComponent(Image)) + expect(cloneTransition.target).to.eq(cloneButtonEntity.getComponent(Image)); const cloneTransitionOne = cloneTransitions[1]; expect(cloneTransitionOne.target).to.eq(cloneButtonEntity.findByName("Text").getComponent(Text));