Skip to content

第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 不支持混合模式
// 如果需要混合模式,使用普通 Container

12.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 练习题

基础练习

  1. 实现圆形头像遮罩

  2. 实现进度条遮罩效果

  3. 使用 ADD 混合模式创建光效

进阶练习

  1. 实现聚光灯效果

  2. 实现场景切换的遮罩动画

挑战练习

  1. 实现一个完整的刮刮卡游戏

下一章预告:在第13章中,我们将深入学习 Ticker 与动画系统。


文档版本:v1.0
字数统计:约 10,000 字
代码示例:40+ 个

如有转载或 CV 的请标注本站原文地址