feat: support mask

This commit is contained in:
cptbtptpbcptdtptp
2026-04-28 15:06:26 +08:00
parent 5c747f0e8d
commit 08682c12fd
30 changed files with 2263 additions and 283 deletions

View File

@@ -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<Texture2D>({
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;
}

View File

@@ -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");
(<UITransform>groupEntity.transform).setPosition(0, 40, 0);
// Circular mask
const maskEntity = groupEntity.createChild("Mask");
(<UITransform>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");
(<UITransform>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");
(<UITransform>labelEntity.transform).size.set(800, 80);
(<UITransform>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);
}

View File

@@ -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");
(<UITransform>maskGroup.transform).setPosition(-300, 60, 0);
const maskEntity = maskGroup.createChild("Mask");
(<UITransform>maskEntity.transform).size.set(280, 280);
const mask = maskEntity.addComponent(Mask);
mask.sprite = solidSprite;
const insideEntity = maskGroup.createChild("InsideImage");
(<UITransform>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");
(<UITransform>maskLabelEntity.transform).size.set(360, 60);
(<UITransform>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");
(<UITransform>rectGroup.transform).setPosition(300, 60, 0);
const viewportEntity = rectGroup.createChild("Viewport");
(<UITransform>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");
(<UITransform>contentEntity.transform).size.set(560, 480);
(<UITransform>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 = <UITransform>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");
(<UITransform>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");
(<UITransform>rectLabelEntity.transform).size.set(360, 60);
(<UITransform>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");
(<UITransform>headerEntity.transform).size.set(900, 80);
(<UITransform>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);
}

85
examples/src/ui-mask.ts Normal file
View File

@@ -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");
(<UITransform>leftGroupEntity.transform).setPosition(-300, 0, 0);
// Square mask
const leftMaskEntity = leftGroupEntity.createChild("Mask");
(<UITransform>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");
(<UITransform>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");
(<UITransform>leftLabelEntity.transform).size.set(300, 60);
(<UITransform>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");
(<UITransform>rightGroupEntity.transform).setPosition(300, 0, 0);
const rightMaskEntity = rightGroupEntity.createChild("Mask");
(<UITransform>rightMaskEntity.transform).size.set(300, 300);
const rightMask = rightMaskEntity.addComponent(Mask);
rightMask.sprite = solidSprite;
const outsideImageEntity = rightGroupEntity.createChild("OutsideImage");
(<UITransform>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");
(<UITransform>rightLabelEntity.transform).size.set(300, 60);
(<UITransform>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);
}

View File

@@ -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");
(<UITransform>outerEntity.transform).size.set(560, 240);
(<UITransform>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");
(<UITransform>innerEntity.transform).size.set(240, 480);
(<UITransform>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");
(<UITransform>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");
(<UITransform>labelTopEntity.transform).size.set(900, 60);
(<UITransform>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");
(<UITransform>labelBottomEntity.transform).size.set(900, 60);
(<UITransform>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);
}

View File

@@ -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");
(<UITransform>frameEntity.transform).size.set(560, 460);
(<UITransform>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");
(<UITransform>viewportEntity.transform).size.set(440, 320);
(<UITransform>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");
(<UITransform>contentEntity.transform).size.set(740, 560);
(<UITransform>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 = <UITransform>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");
(<UITransform>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");
(<UITransform>noteEntity.transform).size.set(360, 220);
(<UITransform>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");
(<UITransform>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);
}

View File

@@ -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<T extends RendererConstructor>(
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
}

View File

@@ -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
}

View File

@@ -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;
}
}
}

View File

@@ -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";

View File

@@ -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<SpriteMask>();
private _allSpriteMasks = new DisorderedArray<IMaskRenderable>();
private _filteredMasksByLayer = new Map<SpriteMaskLayer, IMaskRenderable[]>();
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(

View File

@@ -13,6 +13,36 @@ export class VertexMergeBatcher {
const renderer = <SpriteRenderer>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 &&

View File

@@ -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";

View File

@@ -47,7 +47,6 @@ export class Renderer extends Component {
_globalShaderMacro: ShaderMacroCollection = new ShaderMacroCollection();
@ignoreClone
_renderFrameCount: number;
/** @internal */
@assignmentClone
_maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None;
@assignmentClone

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<IUICanvas>): 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 {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 (<UITransform>this._transformEntity.transform).size.x;
}
override _getSpriteHeight(): number {
return (<UITransform>this._transformEntity.transform).size.y;
}
override _getSpritePivot(): Vector2 {
return (<UITransform>this._transformEntity.transform).pivot;
}
}

View File

@@ -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 = <UITransform>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 链路所以能 superRectMask2D 不在 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;
}
}
}

View File

@@ -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.
*/

View File

@@ -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";

View File

@@ -1,14 +1,43 @@
#include <common>
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;
}
}

View File

@@ -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;
}

View File

@@ -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);
});
});

76
tests/src/ui/Mask.test.ts Normal file
View File

@@ -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);
(<UITransform>imageEntity.transform).size.set(300, 300);
const maskEntity = canvasEntity.createChild("mask");
const mask = maskEntity.addComponent(Mask);
(<UITransform>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);
}

View File

@@ -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);
});
});

View File

@@ -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));