第12章:遮罩与混合模式
12.1 章节概述
遮罩(Mask)和混合模式(Blend Mode)是实现复杂视觉效果的重要工具。遮罩可以控制显示区域,混合模式可以控制颜色如何叠加。
本章将深入讲解:
- 遮罩基础:Graphics 遮罩、Sprite 遮罩
- 遮罩类型:形状遮罩、纹理遮罩
- 混合模式:内置混合模式、自定义混合
- 高级技巧:动态遮罩、遮罩动画
- 性能优化:遮罩使用最佳实践
12.2 遮罩基础
12.2.1 遮罩工作原理
遮罩工作原理
┌─────────────────────────────────────────────────────────────┐
│ 遮罩渲染流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 原始图像 遮罩形状 最终结果 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ │ │ │ │
│ │ ████████ │ + │ ████ │ = │ ████ │ │
│ │ ████████ │ │ ██████ │ │ ██████ │ │
│ │ ████████ │ │ ████ │ │ ████ │ │
│ │ │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ 遮罩决定哪些区域可见 │
│ - 遮罩内部:显示原始图像 │
│ - 遮罩外部:透明/不显示 │
│ │
└─────────────────────────────────────────────────────────────┘
遮罩类型:
1. Graphics 遮罩:使用矢量图形
2. Sprite 遮罩:使用纹理的 alpha 通道12.2.2 Graphics 遮罩
typescript
/**
* Graphics 遮罩
*/
// 创建要被遮罩的对象
const sprite = new PIXI.Sprite(texture);
// 创建遮罩形状
const mask = new PIXI.Graphics();
mask.beginFill(0xFFFFFF);
mask.drawCircle(100, 100, 80);
mask.endFill();
// 应用遮罩
sprite.mask = mask;
// 遮罩需要添加到舞台(或与被遮罩对象相同的容器)
app.stage.addChild(sprite);
app.stage.addChild(mask);
// 各种形状遮罩
// 矩形遮罩
const rectMask = new PIXI.Graphics();
rectMask.beginFill(0xFFFFFF);
rectMask.drawRect(0, 0, 200, 150);
rectMask.endFill();
// 圆角矩形遮罩
const roundedMask = new PIXI.Graphics();
roundedMask.beginFill(0xFFFFFF);
roundedMask.drawRoundedRect(0, 0, 200, 150, 20);
roundedMask.endFill();
// 多边形遮罩
const polyMask = new PIXI.Graphics();
polyMask.beginFill(0xFFFFFF);
polyMask.drawPolygon([0, 0, 100, 0, 100, 100, 50, 150, 0, 100]);
polyMask.endFill();12.2.3 Sprite 遮罩
typescript
/**
* Sprite 遮罩
* 使用纹理的 alpha 通道作为遮罩
*/
// 创建要被遮罩的对象
const sprite = new PIXI.Sprite(texture);
// 创建遮罩精灵
const maskSprite = PIXI.Sprite.from('mask.png');
maskSprite.anchor.set(0.5);
maskSprite.position.set(100, 100);
// 应用遮罩
sprite.mask = maskSprite;
// 添加到舞台
app.stage.addChild(sprite);
app.stage.addChild(maskSprite);
/*
Sprite 遮罩说明:
遮罩纹理的 alpha 值决定可见度:
- alpha = 1: 完全可见
- alpha = 0: 完全不可见
- alpha = 0.5: 半透明
适合复杂形状的遮罩,如:
- 渐变遮罩
- 不规则形状
- 羽化边缘
*/12.2.4 移除遮罩
typescript
/**
* 移除遮罩
*/
// 移除遮罩
sprite.mask = null;
// 如果遮罩不再需要,可以销毁
mask.destroy();
// 临时禁用遮罩
function toggleMask(sprite: PIXI.Sprite, mask: PIXI.Graphics, enabled: boolean) {
sprite.mask = enabled ? mask : null;
}12.3 遮罩应用
12.3.1 圆形头像
typescript
/**
* 圆形头像遮罩
*/
function createCircularAvatar(
texture: PIXI.Texture,
radius: number
): PIXI.Container {
const container = new PIXI.Container();
// 头像图片
const avatar = new PIXI.Sprite(texture);
avatar.anchor.set(0.5);
avatar.width = radius * 2;
avatar.height = radius * 2;
// 圆形遮罩
const mask = new PIXI.Graphics();
mask.beginFill(0xFFFFFF);
mask.drawCircle(0, 0, radius);
mask.endFill();
// 应用遮罩
avatar.mask = mask;
// 可选:添加边框
const border = new PIXI.Graphics();
border.lineStyle(3, 0xFFFFFF);
border.drawCircle(0, 0, radius);
container.addChild(avatar, mask, border);
return container;
}
// 使用
const avatar = createCircularAvatar(avatarTexture, 50);
avatar.position.set(100, 100);
app.stage.addChild(avatar);12.3.2 进度条遮罩
typescript
/**
* 进度条遮罩
*/
class ProgressBar extends PIXI.Container {
private background: PIXI.Sprite;
private fill: PIXI.Sprite;
private mask: PIXI.Graphics;
private _progress: number = 0;
constructor(width: number, height: number) {
super();
// 背景
this.background = new PIXI.Sprite(PIXI.Texture.WHITE);
this.background.width = width;
this.background.height = height;
this.background.tint = 0x333333;
// 填充
this.fill = new PIXI.Sprite(PIXI.Texture.WHITE);
this.fill.width = width;
this.fill.height = height;
this.fill.tint = 0x4CAF50;
// 遮罩
this.mask = new PIXI.Graphics();
this.fill.mask = this.mask;
this.addChild(this.background, this.fill, this.mask);
this.progress = 0;
}
get progress(): number {
return this._progress;
}
set progress(value: number) {
this._progress = Math.max(0, Math.min(1, value));
this.updateMask();
}
private updateMask() {
this.mask.clear();
this.mask.beginFill(0xFFFFFF);
this.mask.drawRect(0, 0, this.background.width * this._progress, this.background.height);
this.mask.endFill();
}
}
// 使用
const progressBar = new ProgressBar(300, 20);
progressBar.progress = 0.7; // 70%12.3.3 揭示效果
typescript
/**
* 揭示效果(刮刮卡)
*/
class ScratchCard extends PIXI.Container {
private revealed: PIXI.Sprite;
private cover: PIXI.Sprite;
private mask: PIXI.Graphics;
private isDrawing: boolean = false;
constructor(revealedTexture: PIXI.Texture, coverTexture: PIXI.Texture) {
super();
// 底层(被揭示的内容)
this.revealed = new PIXI.Sprite(revealedTexture);
// 覆盖层
this.cover = new PIXI.Sprite(coverTexture);
// 遮罩(初始为全覆盖)
this.mask = new PIXI.Graphics();
this.mask.beginFill(0xFFFFFF);
this.mask.drawRect(0, 0, this.cover.width, this.cover.height);
this.mask.endFill();
this.cover.mask = this.mask;
this.addChild(this.revealed, this.cover, this.mask);
// 交互
this.eventMode = 'static';
this.on('pointerdown', this.startDrawing.bind(this));
this.on('pointermove', this.draw.bind(this));
this.on('pointerup', this.stopDrawing.bind(this));
this.on('pointerupoutside', this.stopDrawing.bind(this));
}
private startDrawing(event: PIXI.FederatedPointerEvent) {
this.isDrawing = true;
this.draw(event);
}
private draw(event: PIXI.FederatedPointerEvent) {
if (!this.isDrawing) return;
const localPos = event.getLocalPosition(this);
// 使用 "destination-out" 混合模式擦除遮罩
this.mask.beginFill(0xFFFFFF);
this.mask.drawCircle(localPos.x, localPos.y, 20);
this.mask.endFill();
// 注意:这里需要反向思维
// 我们实际上是在遮罩上"画"出要显示的区域
}
private stopDrawing() {
this.isDrawing = false;
}
}12.3.4 视口遮罩
typescript
/**
* 视口遮罩(滚动区域)
*/
class ScrollContainer extends PIXI.Container {
private content: PIXI.Container;
private mask: PIXI.Graphics;
private viewportWidth: number;
private viewportHeight: number;
constructor(width: number, height: number) {
super();
this.viewportWidth = width;
this.viewportHeight = height;
// 内容容器
this.content = new PIXI.Container();
// 视口遮罩
this.mask = new PIXI.Graphics();
this.mask.beginFill(0xFFFFFF);
this.mask.drawRect(0, 0, width, height);
this.mask.endFill();
this.content.mask = this.mask;
this.addChild(this.content, this.mask);
// 滚动交互
this.setupScrolling();
}
addContent(child: PIXI.DisplayObject) {
this.content.addChild(child);
}
scrollTo(x: number, y: number) {
this.content.x = -x;
this.content.y = -y;
}
private setupScrolling() {
let dragging = false;
let lastY = 0;
this.eventMode = 'static';
this.hitArea = new PIXI.Rectangle(0, 0, this.viewportWidth, this.viewportHeight);
this.on('pointerdown', (e) => {
dragging = true;
lastY = e.global.y;
});
this.on('globalpointermove', (e) => {
if (dragging) {
const dy = e.global.y - lastY;
this.content.y += dy;
// 限制滚动范围
const maxScroll = Math.max(0, this.content.height - this.viewportHeight);
this.content.y = Math.max(-maxScroll, Math.min(0, this.content.y));
lastY = e.global.y;
}
});
this.on('pointerup', () => { dragging = false; });
this.on('pointerupoutside', () => { dragging = false; });
}
}12.4 混合模式
12.4.1 混合模式原理
混合模式原理
混合模式决定了两个图层的颜色如何组合
源颜色 (Source): 正在绘制的颜色
目标颜色 (Destination): 已经存在的颜色
公式:
最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子
常见混合模式:
NORMAL(正常):
结果 = 源颜色
直接覆盖
ADD(加法):
结果 = 源颜色 + 目标颜色
变亮效果
MULTIPLY(乘法):
结果 = 源颜色 × 目标颜色
变暗效果
SCREEN(屏幕):
结果 = 1 - (1 - 源) × (1 - 目标)
变亮效果(与乘法相反)12.4.2 内置混合模式
typescript
/**
* 内置混合模式
*/
const sprite = new PIXI.Sprite(texture);
// 正常(默认)
sprite.blendMode = PIXI.BLEND_MODES.NORMAL;
// 加法(变亮,常用于光效)
sprite.blendMode = PIXI.BLEND_MODES.ADD;
// 乘法(变暗,常用于阴影)
sprite.blendMode = PIXI.BLEND_MODES.MULTIPLY;
// 屏幕(变亮)
sprite.blendMode = PIXI.BLEND_MODES.SCREEN;
// 叠加
sprite.blendMode = PIXI.BLEND_MODES.OVERLAY;
// 变暗
sprite.blendMode = PIXI.BLEND_MODES.DARKEN;
// 变亮
sprite.blendMode = PIXI.BLEND_MODES.LIGHTEN;
// 颜色减淡
sprite.blendMode = PIXI.BLEND_MODES.COLOR_DODGE;
// 颜色加深
sprite.blendMode = PIXI.BLEND_MODES.COLOR_BURN;
// 强光
sprite.blendMode = PIXI.BLEND_MODES.HARD_LIGHT;
// 柔光
sprite.blendMode = PIXI.BLEND_MODES.SOFT_LIGHT;
// 差值
sprite.blendMode = PIXI.BLEND_MODES.DIFFERENCE;
// 排除
sprite.blendMode = PIXI.BLEND_MODES.EXCLUSION;
// 色相
sprite.blendMode = PIXI.BLEND_MODES.HUE;
// 饱和度
sprite.blendMode = PIXI.BLEND_MODES.SATURATION;
// 颜色
sprite.blendMode = PIXI.BLEND_MODES.COLOR;
// 亮度
sprite.blendMode = PIXI.BLEND_MODES.LUMINOSITY;
// 擦除
sprite.blendMode = PIXI.BLEND_MODES.ERASE;
// 源内
sprite.blendMode = PIXI.BLEND_MODES.SRC_IN;
// 源外
sprite.blendMode = PIXI.BLEND_MODES.SRC_OUT;
// 源上
sprite.blendMode = PIXI.BLEND_MODES.SRC_ATOP;
// 目标上
sprite.blendMode = PIXI.BLEND_MODES.DST_OVER;
// 目标内
sprite.blendMode = PIXI.BLEND_MODES.DST_IN;
// 目标外
sprite.blendMode = PIXI.BLEND_MODES.DST_OUT;
// 目标上
sprite.blendMode = PIXI.BLEND_MODES.DST_ATOP;
// 异或
sprite.blendMode = PIXI.BLEND_MODES.XOR;12.4.3 混合模式应用
typescript
/**
* 混合模式应用示例
*/
// 光效(ADD)
function createLightEffect(x: number, y: number, color: number): PIXI.Sprite {
const light = PIXI.Sprite.from('light.png');
light.anchor.set(0.5);
light.position.set(x, y);
light.tint = color;
light.blendMode = PIXI.BLEND_MODES.ADD;
return light;
}
// 阴影(MULTIPLY)
function createShadow(target: PIXI.Sprite): PIXI.Sprite {
const shadow = new PIXI.Sprite(target.texture);
shadow.anchor.copyFrom(target.anchor);
shadow.scale.set(1, 0.5);
shadow.skew.x = -0.5;
shadow.tint = 0x000000;
shadow.alpha = 0.3;
shadow.blendMode = PIXI.BLEND_MODES.MULTIPLY;
return shadow;
}
// 高光(SCREEN)
function createHighlight(target: PIXI.Sprite): PIXI.Sprite {
const highlight = new PIXI.Sprite(target.texture);
highlight.anchor.copyFrom(target.anchor);
highlight.tint = 0xFFFFFF;
highlight.alpha = 0.3;
highlight.blendMode = PIXI.BLEND_MODES.SCREEN;
return highlight;
}
// 颜色叠加(COLOR)
function applyColorOverlay(target: PIXI.Sprite, color: number): PIXI.Sprite {
const overlay = new PIXI.Sprite(PIXI.Texture.WHITE);
overlay.width = target.width;
overlay.height = target.height;
overlay.tint = color;
overlay.blendMode = PIXI.BLEND_MODES.COLOR;
return overlay;
}12.5 高级技巧
12.5.1 动态遮罩
typescript
/**
* 动态遮罩
*/
// 跟随鼠标的聚光灯效果
class Spotlight extends PIXI.Container {
private content: PIXI.Sprite;
private mask: PIXI.Graphics;
private radius: number;
constructor(texture: PIXI.Texture, radius: number = 100) {
super();
this.radius = radius;
// 内容
this.content = new PIXI.Sprite(texture);
// 遮罩
this.mask = new PIXI.Graphics();
this.content.mask = this.mask;
this.addChild(this.content, this.mask);
// 更新遮罩位置
this.updateMask(0, 0);
}
updateMask(x: number, y: number) {
this.mask.clear();
this.mask.beginFill(0xFFFFFF);
this.mask.drawCircle(x, y, this.radius);
this.mask.endFill();
}
setRadius(radius: number) {
this.radius = radius;
}
}
// 使用
const spotlight = new Spotlight(backgroundTexture, 150);
app.stage.addChild(spotlight);
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
app.stage.on('pointermove', (e) => {
spotlight.updateMask(e.global.x, e.global.y);
});12.5.2 渐变遮罩
typescript
/**
* 渐变遮罩
*/
// 创建渐变遮罩纹理
function createGradientMaskTexture(
width: number,
height: number,
direction: 'horizontal' | 'vertical' = 'vertical'
): PIXI.Texture {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
let gradient: CanvasGradient;
if (direction === 'vertical') {
gradient = ctx.createLinearGradient(0, 0, 0, height);
} else {
gradient = ctx.createLinearGradient(0, 0, width, 0);
}
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
return PIXI.Texture.from(canvas);
}
// 使用
const gradientMask = new PIXI.Sprite(
createGradientMaskTexture(400, 300, 'vertical')
);
sprite.mask = gradientMask;
// 径向渐变遮罩
function createRadialGradientMask(
radius: number
): PIXI.Texture {
const size = radius * 2;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d')!;
const gradient = ctx.createRadialGradient(
radius, radius, 0,
radius, radius, radius
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.5)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
return PIXI.Texture.from(canvas);
}12.5.3 遮罩动画
typescript
/**
* 遮罩动画
*/
// 圆形展开动画
class CircleReveal extends PIXI.Container {
private content: PIXI.DisplayObject;
private mask: PIXI.Graphics;
private centerX: number;
private centerY: number;
private maxRadius: number;
private currentRadius: number = 0;
constructor(
content: PIXI.DisplayObject,
centerX: number,
centerY: number,
maxRadius: number
) {
super();
this.content = content;
this.centerX = centerX;
this.centerY = centerY;
this.maxRadius = maxRadius;
this.mask = new PIXI.Graphics();
this.content.mask = this.mask;
this.addChild(this.content, this.mask);
}
async animate(duration: number): Promise<void> {
const startTime = performance.now();
return new Promise((resolve) => {
const update = () => {
const elapsed = performance.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// 缓动函数
const eased = 1 - Math.pow(1 - progress, 3);
this.currentRadius = this.maxRadius * eased;
this.updateMask();
if (progress < 1) {
requestAnimationFrame(update);
} else {
resolve();
}
};
update();
});
}
private updateMask() {
this.mask.clear();
this.mask.beginFill(0xFFFFFF);
this.mask.drawCircle(this.centerX, this.centerY, this.currentRadius);
this.mask.endFill();
}
}
// 使用
const reveal = new CircleReveal(sprite, 400, 300, 500);
app.stage.addChild(reveal);
await reveal.animate(1000);
// 百叶窗效果
class BlindsReveal extends PIXI.Container {
private content: PIXI.DisplayObject;
private mask: PIXI.Graphics;
private blindCount: number;
private width: number;
private height: number;
constructor(
content: PIXI.DisplayObject,
width: number,
height: number,
blindCount: number = 10
) {
super();
this.content = content;
this.width = width;
this.height = height;
this.blindCount = blindCount;
this.mask = new PIXI.Graphics();
this.content.mask = this.mask;
this.addChild(this.content, this.mask);
}
async animate(duration: number): Promise<void> {
const blindWidth = this.width / this.blindCount;
const startTime = performance.now();
return new Promise((resolve) => {
const update = () => {
const elapsed = performance.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
this.mask.clear();
this.mask.beginFill(0xFFFFFF);
for (let i = 0; i < this.blindCount; i++) {
const blindProgress = Math.max(0, Math.min(1, progress * 2 - i / this.blindCount));
const blindHeight = this.height * blindProgress;
this.mask.drawRect(
i * blindWidth,
0,
blindWidth,
blindHeight
);
}
this.mask.endFill();
if (progress < 1) {
requestAnimationFrame(update);
} else {
resolve();
}
};
update();
});
}
}12.6 性能优化
12.6.1 遮罩性能考虑
typescript
/**
* 遮罩性能优化
*/
// 1. 避免频繁更新遮罩
// 不好:每帧重绘遮罩
app.ticker.add(() => {
mask.clear();
mask.beginFill(0xFFFFFF);
mask.drawCircle(x, y, radius);
mask.endFill();
});
// 好:只在需要时更新
let lastX = 0, lastY = 0;
app.ticker.add(() => {
if (x !== lastX || y !== lastY) {
mask.clear();
mask.beginFill(0xFFFFFF);
mask.drawCircle(x, y, radius);
mask.endFill();
lastX = x;
lastY = y;
}
});
// 2. 使用简单形状
// 复杂的多边形遮罩比简单的矩形/圆形更耗性能
// 3. 考虑使用 Sprite 遮罩代替复杂 Graphics 遮罩
// 对于复杂形状,预渲染的纹理可能更高效
// 4. 限制遮罩数量
// 每个遮罩都需要额外的渲染通道12.6.2 混合模式性能
typescript
/**
* 混合模式性能优化
*/
// 1. 某些混合模式比其他模式更耗性能
// 高性能:NORMAL, ADD, MULTIPLY, SCREEN
// 低性能:复杂的混合模式(如 COLOR, LUMINOSITY)
// 2. 批量渲染中断
// 不同混合模式的对象会中断批量渲染
// 尽量将相同混合模式的对象放在一起
// 3. 使用 ParticleContainer 时
// ParticleContainer 不支持混合模式
// 如果需要混合模式,使用普通 Container12.7 本章小结
核心概念
| 概念 | 说明 |
|---|---|
| mask | 遮罩属性 |
| Graphics 遮罩 | 使用矢量图形作为遮罩 |
| Sprite 遮罩 | 使用纹理 alpha 通道作为遮罩 |
| blendMode | 混合模式 |
| ADD | 加法混合(变亮) |
| MULTIPLY | 乘法混合(变暗) |
| SCREEN | 屏幕混合(变亮) |
关键代码
typescript
// Graphics 遮罩
const mask = new PIXI.Graphics();
mask.beginFill(0xFFFFFF);
mask.drawCircle(100, 100, 50);
mask.endFill();
sprite.mask = mask;
// Sprite 遮罩
const maskSprite = PIXI.Sprite.from('mask.png');
sprite.mask = maskSprite;
// 移除遮罩
sprite.mask = null;
// 混合模式
sprite.blendMode = PIXI.BLEND_MODES.ADD;
sprite.blendMode = PIXI.BLEND_MODES.MULTIPLY;
sprite.blendMode = PIXI.BLEND_MODES.SCREEN;12.8 练习题
基础练习
实现圆形头像遮罩
实现进度条遮罩效果
使用 ADD 混合模式创建光效
进阶练习
实现聚光灯效果
实现场景切换的遮罩动画
挑战练习
- 实现一个完整的刮刮卡游戏
下一章预告:在第13章中,我们将深入学习 Ticker 与动画系统。
文档版本:v1.0
字数统计:约 10,000 字
代码示例:40+ 个
