mirror of
https://github.com/galacean/engine.git
synced 2026-06-20 19:06:15 +08:00
feat: support mask
This commit is contained in:
87
examples/src/sprite-mask.ts
Normal file
87
examples/src/sprite-mask.ts
Normal 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;
|
||||
}
|
||||
97
examples/src/ui-mask-alpha.ts
Normal file
97
examples/src/ui-mask-alpha.ts
Normal 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);
|
||||
}
|
||||
120
examples/src/ui-mask-overlay.ts
Normal file
120
examples/src/ui-mask-overlay.ts
Normal 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
85
examples/src/ui-mask.ts
Normal 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);
|
||||
}
|
||||
76
examples/src/ui-rect-mask-nested.ts
Normal file
76
examples/src/ui-rect-mask-nested.ts
Normal 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);
|
||||
}
|
||||
135
examples/src/ui-rect-mask.ts
Normal file
135
examples/src/ui-rect-mask.ts
Normal 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);
|
||||
}
|
||||
398
packages/core/src/2d/sprite/MaskRenderable.ts
Normal file
398
packages/core/src/2d/sprite/MaskRenderable.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
136
packages/core/src/2d/sprite/SpriteMaskUtils.ts
Normal file
136
packages/core/src/2d/sprite/SpriteMaskUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -47,7 +47,6 @@ export class Renderer extends Component {
|
||||
_globalShaderMacro: ShaderMacroCollection = new ShaderMacroCollection();
|
||||
@ignoreClone
|
||||
_renderFrameCount: number;
|
||||
/** @internal */
|
||||
@assignmentClone
|
||||
_maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None;
|
||||
@assignmentClone
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
80
packages/ui/src/component/advanced/Mask.ts
Normal file
80
packages/ui/src/component/advanced/Mask.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
157
packages/ui/src/component/advanced/RectMask2D.ts
Normal file
157
packages/ui/src/component/advanced/RectMask2D.ts
Normal 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 链路所以能 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
142
tests/src/core/particle/ParticleStopResume.test.ts
Normal file
142
tests/src/core/particle/ParticleStopResume.test.ts
Normal 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
76
tests/src/ui/Mask.test.ts
Normal 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);
|
||||
}
|
||||
79
tests/src/ui/RectMask2D.test.ts
Normal file
79
tests/src/ui/RectMask2D.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user