大家好,我是前端西瓜哥。
经常用 pixijs 的小伙伴或许注意到了,在 pixijs 下绘制多边形并使用填充,在很多情况下渲染是正确的,如果多边形是自交的,填充就会有问题。
这里使用的 pixi.js 版本为 7.4.2
我们写一段 pixijs 代码,绘制一个自交多边形。
ini
import * as PIXI from'pixi.js';
const app = new PIXI.Application({
background: '#eee',
antialias: true,
});
const polygonPoints = [
{ x: 200, y: 27 },
{ x: 200, y: 200 },
{ x: 233, y: 110 },
{ x: 176, y: 181 },
{ x: 98, y: 138 },
{ x: 95, y: 68 },
{ x: 130, y: 27 },
];
const polygon = new PIXI.Graphics()
.beginFill(0xff0000, 0.3)
.lineStyle(2, 0x000000)
.drawPolygon(polygonPoints);
app.stage.addChild(polygon);
document.body.appendChild(app.view);
渲染结果:

我们来看看是什么会导致这个原因。
在线示例
这里提供一个在线示例,读者可以修改多边形的点,观测不同三角剖分的在各种场景下的区别。
ear cutting 三角剖分
首先需要理解 pixijs 的图形是怎么渲染的?
pixijs 是使用 WebGL 进行渲染的,WebGL 渲染的常见方式是绘制三角形,大量的三角形进行组合,便绘制出了图形。
对于 pixijs,其图形的填充和描边,分别都需要 通过三角剖分为顶点,并设置颜色信息,然后提供给 WebGL 调用绘制。
三角剖分(triangulation)是一种将图形转为多个三角形表达的算法,是计算机图形学中的经典课题。比如矩形可以用两个三角形表达,圆形可以用一圈的三角形表达。
pixijs 选择了使用 earcut 的第三方库来队多边形进行三角剖分。
earcut 库实现了一种改进的耳切(ear cutting)算法。
所谓耳切算法,就是遍历多边形上的点不断地将突出的三角形 "耳朵" 切掉,直到切完整个多边形,这些切出来的三角形就是我们想要的最终结果。
如下图,连续的点 2、3、4 构成的三角形不会包含其他的点,把它作为 "耳朵" 切割出来。

earcut 库的优点是快且小(压缩后 3KB),因为牺牲了质量,对于 pixijs 这种追求高性能高速度的渲染引擎,是合适的选择。
但问题在于 ear cutting 算法限制很大,只支持简单多边形,像是自交的多边形,渲染结果可能不符合预期,这便是 pixijs 的填充效果错误的原因。
简单多边形效果是正确的。
scss
const polygon = [ { x: 200, y: 27 }, { x: 180, y: 100 }, { x: 233, y: 110 }, { x: 176, y: 181 }, { x: 98, y: 138 }, { x: 95, y: 68 }, { x: 130, y: 27 },];
earcut(polygon.map((p) => [p.x, p.y]).flat(), [], 2);
// 结果如下,三个一组构成三角形
// [
// 5, 6, 0,
// 1, 2, 3,
// 3, 4, 5,
// 5, 0, 1,
// 1, 3, 5
// ]
但如果多边形自交了,渲染明显会相当怪异,不符合预期。
一般我们需要 even-odd 或 non-zero。

即使不用这两种填充规则,用另外的填充规则,比如 figma 的将所有封闭区域都填充,ear cutting 这种把内容填充到多边形外也是很奇怪的。
scss
const polygon = [ { x: 200, y: 27 }, { x: 200, y: 200 }, { x: 233, y: 110 }, { x: 176, y: 181 }, { x: 98, y: 138 }, { x: 95, y: 68 }, { x: 130, y: 27 },];
earcut(polygon.map((p) => [p.x, p.y]).flat(), [], 2);
// 结果如下,三个一组构成三角形
// [
// 5, 6, 0,
// 2, 3, 4,
// 4, 5, 0,
// 2, 4, 0
// ]

tess 三角剖分
为此我们需要换个合适的,能处理复杂情况的算法。
我们可以看看 pixijs 的一个第三方库 @pixi-essentials/svg
,这个库的作用是将 svg 转为 pixijs 的图形进行渲染。
所以需要保持 svg 原有的正确填充效果,这个库使用了 libtess 来做三角剖分。
libtess 是一个多边形镶嵌(polygon tesselation)库,从 OpenGL Utility Library(GLU)移植过来,用 javascript 重写。
对于前面用到的示例,我们换成 libtess 来试试。
因为是基于 GLU 迁移过来的,所以 API 挺复杂的,出于易用性,对 libtess 做了一层封装。
下面是 libtess 的示例中一个封装好的方法。
scss
import * as libtess from'libtess';
const tessy = (function initTesselator() {
// function called for each vertex of tesselator output
function vertexCallback(data, polyVertArray) {
polyVertArray[polyVertArray.length] = data[0];
polyVertArray[polyVertArray.length] = data[1];
}
function begincallback(type) {
if (type !== libtess.primitiveType.GL_TRIANGLES) {
console.log('expected TRIANGLES but got type: ' + type);
}
}
function errorcallback(errno) {
console.log('error callback');
console.log('error number: ' + errno);
}
// callback for when segments intersect and must be split
function combinecallback(coords, data, weight) {
// console.log('combine callback');
return [coords[0], coords[1], coords[2]];
}
function edgeCallback(flag) {
// don't really care about the flag, but need no-strip/no-fan behavior
// console.log('edge flag: ' + flag);
}
var tessy = new libtess.GluTesselator();
// tessy.gluTessProperty(libtess.gluEnum.GLU_TESS_WINDING_RULE, libtess.windingRule.GLU_TESS_WINDING_POSITIVE);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_VERTEX_DATA, vertexCallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_BEGIN, begincallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_ERROR, errorcallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_COMBINE, combinecallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_EDGE_FLAG, edgeCallback);
return tessy;
})();
function triangulate(contours) {
// libtess will take 3d verts and flatten to a plane for tesselation
// since only doing 2d tesselation here, provide z=1 normal to skip
// iterating over verts only to get the same answer.
// comment out to test normal-generation code
tessy.gluTessNormal(0, 0, 1);
var triangleVerts = [];
tessy.gluTessBeginPolygon(triangleVerts);
for (var i = 0; i < contours.length; i++) {
tessy.gluTessBeginContour();
var contour = contours[i];
for (var j = 0; j < contour.length; j += 2) {
var coords = [contour[j], contour[j + 1], 0];
tessy.gluTessVertex(coords, coords);
}
tessy.gluTessEndContour();
}
// finish polygon (and time triangulation process)
tessy.gluTessEndPolygon();
return triangleVerts;
}
下面是简单多边形的效果,这里还对比了 earcut 的效果。
scss
const arr = polygon.map((p) => [p.x, p.y]).flat();
triangulate([arr]);
// 返回的不是索引,是图形的点位置,
// 两个为一个点,三个点为一个三角形
// [
// 130, 27,
// 98, 138,
// 95, 68,
//
// 98, 138,
// 130, 27,
// 176, 181,
//
// 176, 181,
// 130, 27,
// 180, 100,
//
// 180, 100,
// 130, 27,
// 200, 27,
//
// 233, 110,
// 176, 181,
// 180, 100
// ]

然后是自交多边形,这次渲染对了。

除了 libtess 库,还有其他的第三方库也迁移了 GLU tesselator。有:
-
tess2-wasm:github.com/eXponenta/e...
具体不同库的效果谁更好我没有研究过,但这里暂且列出一些以供读者选择和测试。
pixijs 上如何改造?
下面我们看如何给 pixijs 用上这个新的三角剖分算法。
说实在的,pixijs 的改造方式算不上很优雅,但还是可以改的。
pixijs 暴露了 graphicsUtils 对象,该对象下放置了不同图形的三角化逻辑。
css
declare const graphicsUtils: {
// 多边形
buildPoly: IShapeBuildCommand;
buildCircle: IShapeBuildCommand;
buildRectangle: IShapeBuildCommand;
buildRoundedRectangle: IShapeBuildCommand;
buildLine: typeof buildLine;
// ...
};
我们需要改一下 graphicsUtils.buildPoly.triangulate
,设置为我们的新的 triangulate 方法。
这里用的是 tess2 库。
ini
import { graphicsUtils } from'pixi.js';
import * as Tess2 from'tess2';
// 重写原来的 buildPoly.triangulate 方法
graphicsUtils.buildPoly.triangulate = triangulate;
function triangulate(graphicsData, graphicsGeometry) {
let points = graphicsData.points;
const holes = graphicsData.holes;
const verts = graphicsGeometry.points;
const indices = graphicsGeometry.indices;
if (points.length >= 6) {
const holeArray = [];
for (let i = 0; i < holes.length; i++) {
const hole = holes[i];
holeArray.push(points.length / 2);
points = points.concat(hole.points);
}
// Tesselate
const res = Tess2.tesselate({
contours: [points],
windingRule: Tess2.WINDING_ODD,
elementType: Tess2.POLYGONS,
polySize: 3,
vertexSize: 2,
});
if (!res.elements.length) {
return;
}
const vrt = res.vertices;
const elm = res.elements;
const vertPos = verts.length / 2;
for (var i = 0; i < elm.length; i++) {
indices.push(elm[i] + vertPos);
}
for (let i = 0; i < vrt.length; i++) {
verts.push(vrt[i]);
}
}
}
这样就用上了 tess2 的三角剖切,复杂的多边形也能正确渲染了。
这次填充渲染就对了。

如果你想要多种多边形三角剖分都同时存在,可能需要考虑继承一下 Graphics 类,然后在 renderer 的时候设置好 buildPoly.triangulate
。
ini
class DefGraphics extends PIXI.Graphics {
render(renderer) {
PIXI.graphicsUtils.buildPoly.triangulate = defTriangulator;
super.render(renderer);
}
}
class TessGraphics extends PIXI.Graphics {
render(renderer) {
PIXI.graphicsUtils.buildPoly.triangulate = triangulate;
super.render(renderer);
}
}
在线示例:
最后
pixijs 为了提高渲染速度,在质量上做了妥协,如果你的项目对质量有要求,就需要进行特殊的调整,就像这里的三角剖分的算法一样,希望对你有所帮助。
我是前端西瓜哥,关注我,学习更多图形渲染知识。
相关阅读,