我不知道的 Pixi.js(06)— 图形与遮罩的渲染机制
Pixi.js 的 Graphics 类用来绘制矢量图形——矩形、圆形、线条、多边形。但 v8 对 Graphics API 做了一次大幅重构,beginFill()/endFill() 这套老接口被移除了,换成了链式调用 + 独立的 fill()/stroke() 方法。如果…
Pixi.js 的 Graphics 类用来绘制矢量图形——矩形、圆形、线条、多边形。但 v8 对 Graphics API 做了一次大幅重构,beginFill()/endFill() 这套老接口被移除了,换成了链式调用 + 独立的 fill()/stroke() 方法。如果你还在用 v7 的写法,升级后会直接报错。
一、v8 的 Graphics API:链式调用的新风格
先看 v7 和 v8 的对比,感受一下变化幅度:
// v7 — beginFill/endFill 模式
const g = new PIXI.Graphics();
g.beginFill(0xff0000);
g.drawRect(10, 10, 100, 50);
g.endFill();
g.lineStyle(2, 0x000000);
g.drawCircle(200, 200, 40);
// v8 — 链式调用 + 独立 fill/stroke
import { Graphics } from 'pixi.js';
const g = new Graphics();
g.rect(10, 10, 100, 50).fill(0xff0000);
g.circle(200, 200, 40).stroke({ width: 2, color: 0x000000 });
说白了,v8 把”画形状”和”填充/描边”拆成了两步:先用 rect()、circle()、poly() 等方法定义路径,再用 fill() 或 stroke() 决定如何渲染。这和 Canvas 2D 的 ctx.beginPath() → ctx.rect() → ctx.fill() 逻辑很像。
v8 支持的路径方法:
g.rect(x, y, width, height); // 矩形
g.circle(x, y, radius); // 圆形
g.ellipse(x, y, halfWidth, halfHeight); // 椭圆
g.roundRect(x, y, w, h, radius); // 圆角矩形
g.poly([x1, y1, x2, y2, ...]); // 多边形
g.moveTo(x, y).lineTo(x2, y2); // 线段
g.bezierCurveTo(cx1, cy1, cx2, cy2, x, y); // 三次贝塞尔
g.quadraticCurveTo(cx, cy, x, y); // 二次贝塞尔
这里有一个很多人会忽略的细节——fill() 和 stroke() 只作用于前面最近的路径。如果想给多个形状用不同颜色,需要在每个路径后分别调用:
g.rect(0, 0, 100, 100).fill('red');
g.rect(120, 0, 100, 100).fill('blue');
// 两个矩形各自填充,互不干扰
二、三角化:矢量路径怎么变成 GPU 能画的东西
GPU 只认三角形。无论是矩形、圆形还是贝塞尔曲线,最终都要拆解成三角形(triangulation)才能送进渲染管线。
矩形最简单——两个三角形拼成一个矩形。圆形复杂一些——Pixi.js 把圆形近似为多边形(默认足够多的边),再对多边形做三角化。贝塞尔曲线更复杂——先用 de Casteljau 算法细分成一系列线段,线段围成区域后再三角化。
换句话说,Graphics 的渲染成本和路径的复杂度正相关。一个简单矩形只要 2 个三角形,一个带大量贝塞尔曲线的复杂路径可能需要上百个三角形。
这才是真正的原因——为什么频繁修改 Graphics 路径会严重影响性能。每次修改路径(调用 clear() 后重新绘制),引擎需要重新做三角化计算,这是一个 CPU 密集型操作。对于静态图形,Pixi.js 会缓存三角化结果;对于每帧都变化的图形(比如动态曲线),三角化开销是无法避免的。
问题的关键在于——如果图形是静态的,考虑用 graphics.cacheAsTexture(true) 把矢量图形转成纹理。转成纹理后,渲染成本和 Sprite 一样,只是一次贴图操作,不再需要每帧三角化。代价是失去矢量的无损缩放能力。
const g = new Graphics();
g.circle(0, 0, 100).fill('red');
g.cacheAsTexture(true); // v8 替代了 v7 的 cacheAsBitmap
// 之后 g 的渲染成本等同于一个 Sprite
三、遮罩:用形状裁切显示区域
遮罩(Mask)控制一个对象只显示特定区域内的内容。Pixi.js 支持两种遮罩实现方式,底层机制完全不同。
方式一:Graphics 遮罩(Stencil Mask)
import { Sprite, Graphics } from 'pixi.js';
const photo = new Sprite(texture);
const circleMask = new Graphics();
circleMask.circle(150, 150, 100).fill(0xffffff);
photo.mask = circleMask;
photo.addChild(circleMask); // 遮罩需要加入场景树
底层实现:引擎先把 circleMask 的形状写入 GPU 的 Stencil Buffer(模板缓冲区),然后渲染 photo 时只画和 Stencil Buffer 匹配的像素区域。遮罩的颜色无所谓,只有形状起作用。
方式二:Sprite 遮罩(Alpha Mask)
const maskSprite = new Sprite(gradientTexture);
photo.mask = maskSprite;
photo.addChild(maskSprite);
底层实现:引擎把 photo 和 maskSprite 分别渲染到临时 RenderTarget,然后用 maskSprite 的 alpha 通道作为权重合成。alpha 为 1 的区域完全显示,alpha 为 0 的区域完全透明。这种方式支持渐变遮罩、羽化边缘等 Stencil 做不到的效果。
换句话说,Graphics 遮罩是”硬裁切”(要么显示要么不显示),Sprite 遮罩是”软裁切”(可以半透明过渡)。
四、遮罩的性能差异
两种遮罩的性能特征差距很大:
Stencil Mask(Graphics 遮罩):
- 不需要额外的 RenderTarget
- 利用 GPU 的硬件 Stencil 测试,开销很小
- 适合简单形状(圆形、矩形、多边形)
- 不支持渐变/半透明边缘
Alpha Mask(Sprite 遮罩):
- 需要两个额外的 RenderTarget(对象和遮罩各渲染一次)
- 需要一次全屏合成绘制
- 适合复杂遮罩效果(渐变、纹理遮罩)
- 性能开销显著高于 Stencil Mask
如果你只记住一句话:能用 Graphics 遮罩就不用 Sprite 遮罩。简单形状裁切用 Stencil 几乎无性能损失,Sprite 遮罩每个至少增加两次 RenderTarget 切换。
五、实战:动态遮罩和性能优化
一个常见场景是”探照灯效果”——一个圆形遮罩跟随鼠标移动:
const scene = new Sprite(backgroundTexture);
const spotlight = new Graphics();
spotlight.circle(0, 0, 80).fill(0xffffff);
scene.mask = spotlight;
scene.addChild(spotlight);
app.stage.addChild(scene);
app.stage.eventMode = 'static';
app.stage.on('pointermove', (event) => {
spotlight.position.copyFrom(event.global);
});
这段代码工作正常,但有一个潜在的性能问题——每次移动鼠标时 spotlight 的位置变化会触发 Stencil Buffer 的重新写入。对于简单形状这不是问题,但如果遮罩是复杂多边形,重写 Stencil 的三角化开销会比较大。
优化方案是用 hitArea 配合矩形遮罩替代复杂形状,或者在遮罩不变时用 cacheAsTexture 缓存:
// 如果遮罩形状不变(只是位置变),不需要特殊优化
// Pixi.js 会复用三角化缓存,只更新位置变换矩阵
// 但如果每帧 clear() 重绘遮罩形状,三角化缓存就失效了
总结
v8 的 Graphics API 从 beginFill/endFill 模式切换到了链式调用 + 独立 fill()/stroke() 模式。矢量图形的渲染核心是三角化——路径越复杂,CPU 开销越大。遮罩有两种:Stencil Mask 利用硬件模板缓冲做硬裁切,性能好但不支持渐变;Alpha Mask 用 RenderTarget 做软裁切,效果好但性能贵。
本系列其他文章:
- 上一篇:动画系统的 Ticker 解析
- 下一篇:性能优化与调试实践
延伸阅读: