fabric 和 konva 主要是用于实现编辑器的场景,而 Pixi 则是一个高性能 2D 动画渲染库,通常用于一些 H5 的小游戏或可交互页面。
本次通过以下几个方面来对其进行分析:
- WebGL 与 Canvas 渲染器
- 资源加载器与纹理
- 场景、精灵与图形对象
- 变换、交互及动画处理
系列目录
- Canvas2D 渲染库简析:(一)Fabric
- Canvas2D 渲染库简析:(二)Konva
- Canvas2D 渲染库简析:(三)Pixi
Pixi
Pixi是一个基于 WebGL Renderer 的高性能跨平台渲染库。其中默认使用 WebGL 相关插件 (回退使用 CanvasRenderer) 去渲染 2D 图形,并且在资源加载和动画处理方面也有比较好的设计和优化。
本文所用的 Pixi 版本为 5.2.0。
在使用 Pixi 前,需要创建一个 Application 对象,作为最外层的应用对象。
Application 是 Pixi 中统领全局的对象,其中包含了使用的渲染器 (render)、舞台 (stage)、安装的插件等主要属性及操作器。
export class Application {constructor(options){options = Object.assign({forceCanvas: false,}, options);this.renderer = autoDetectRenderer(options);this.stage = new Container();Application._plugins.forEach((plugin) =>{plugin.init.call(this, options);});}}复制代码
提供的方法也是从 stage 和 renderer 对象中取得的属性或其他操作,如 view(), screen() 等。
渲染器
可以看到在 App 的创建过程中,会根据当前环境选择可用的渲染器。
默认采用 WebGLRenderer,若当前浏览器环境不支持 WebGL 则使用 Canvas。根据渲染方式初始化对应的 renderer
- WenGL: WebGLRenderer
- Canvas: CanvasRenderer
这两种渲染器均实现自 AbstractRenderer 类,在这个类中保存了渲染器所的绑定的 canvas 元素、设置透明度与分辨率等属性。
WebGLRenderer
packages/core/src/Renderer
在 WebGLRenderer 的初始化过程中,会在 Renderer 类上注册不同类型的系统插件 (均继承自 System 类),如上下文插件(ContextSystem)、着色器插件(ShaderSystem)、纹理插件(TextureSystem) 等等,并且在注册系统插件时会插入代表不同阶段的生命周期钩子(runner: prerender | postrender | resize | update | contextChange),
来看看 System 这个类,其实很简单,就是用一个于在 renderer 类上扩展相关属性与方法的类。
export class System {
constructor(renderer) {
this.renderer = renderer;
}
destroy() {
this.renderer = null;
}
}
复制代码
这些 System 插件主要有:
- GeometrySystem - 管理 VAO(VertexArrayObject)数据的相关操作及缓冲区 (buffer) 操作
- StateSystem - 当前 WebGL 状态机,处理 offset、blend 和 depth test 等状态
- ShaderSystem - 管理顶点与片元着色器,如其中 attribute 和 uniform 属性的操作,也有常规的解析 shader 和绑定 program 等过程
- MaskSystem - 管理图形遮罩,按照指定几何图形的范围显示纹理图像
- FilterSystem - 管理滤镜,处理纹理变换
作为一个 renderer,最重要的方法即是它的 render()方法,它的执行过程 (省去了生命周期函数) 如下:
render(displayObject, renderTexture, clear, transform, skipUpdateTransform) {
this.projection.transform = transform;
this.renderTexture.bind(renderTexture);
this.batch.currentRenderer.start();
displayObject.render();
this.batch.currentRenderer.flush();
this.projection.transform = null;
}
复制代码
有关渲染的工作主要由 BatchSystem 插件负责执行,BatchRenderer
CanvasRenderer
packages/canvas/canvas-renderer/src/CanvasRenderer
较 WebGLRenderer 的实现比较简单,在构建函数中并没有加载其他插件,仅初始化了一些属性,如 mask 与 blendMode 等,
CanvasRenderer 的 render() 执行流程如下:
render(displayObject, renderTexture, clear, transform, skipUpdateTransform) {
const context = this.context;
context.save();
context.setTransform(1, 0, 0, 1, 0, 0);
context.globalAlpha = 1;
this._activeBlendMode = BLEND_MODES.NORMAL;
this._outerBlend = false;
context.globalCompositeOperation = this.blendModes[BLEND_MODES.NORMAL];
const tempContext = this.context;
this.context = context;
displayObject.renderCanvas(this);
this.context = tempContext;
context.restore();
}
复制代码
场景、精灵与图形
场景 - Stage
Stage 本质是一个 Container 对象,与 Konva 中的概念类似。
Pixi 的Container是一种 DisplayObject 容器,负责 children 的管理、变换的应用及包围盒 (bounds) 计算。Container 中可以包含精灵 (Sprite) 或图形 (Graphic) 对象,实现分组的效果,需要注意的是在 Container 应用的变换会作用到所有子元素上。
DisplayObject是显示的基础元素,其中包含元素的变换矩阵、alpha 系数和层级系数等属性及相关数据操作的方法,每个继承它的类的对象要想渲染出来必须实现它的_render 方法。
精灵 - Sprite
Pixi 中的精灵 (Sprite) 为一种可交互的纹理对象,继承自 Container 类,因此也可以嵌套其他 DisplayObject 对象,形成图形树。
Sprite 类中包含用于顶点计算和目标检测等方法,用于为渲染提供关键数据及为交互事件的处理提供辅助方法等。
vertex 的计算
calculateVertices() {
const texture = this._texture;
const wt = this.transform.worldTransform;
const tx = wt.tx;
const vertexData = this.vertexData;
const anchor = this._anchor;
let w1 = -anchor._x * orig.width;
let w0 = w1 + orig.width;
let h1 = -anchor._y * orig.height;
let h0 = h1 + orig.height;
vertexData[0] = (a * w1) + (c * h1) + tx;
vertexData[1] = (d * h1) + (b * w1) + ty;
}
复制代码
判断点是否在该精灵的区域中
containsPoint(point) {
this.worldTransform.applyInverse(point, tempPoint);
const width = this._texture.orig.width;
const height = this._texture.orig.height;
const x1 = -width * this.anchor.x;
let y1 = 0;
if (tempPoint.x >= x1 && tempPoint.x < x1 + width) {
y1 = -height * this.anchor.y;
if (tempPoint.y >= y1 && tempPoint.y < y1 + height) {
return true;
}
}
return false;
}
复制代码
在 Sprite 类中默认使用 BatchRenderer 对精灵进行渲染,BatchRenderer 为 WebGLRenderer 中的一个插件,用于记录相关数据,统一执行绘制 (flush)。
this.pluginName = 'batch';
_render(renderer) {
this.calculateVertices();
renderer.batch.setObjectRenderer(renderer.plugins[this.pluginName]);
renderer.plugins[this.pluginName].render(this);
}
复制代码
图形 - Graphic
在场景中除了加载纹理图像生成的精灵外,还可以通过常规或自定义的几何图形来添加图形对象,
Graphic中提供类似 CanvasContext 上的绘图 API,比如 drawRect、drawCircle 等,将这些基础图形的数据经过处理后 (如三角化),再使用 WebGL 的 API 进行绘制。Graphic 同样继承自 Container 类。
drawRect(x, y, width, height) {
return this.drawShape(new Rectangle(x, y, width, height));
}
复制代码
对于每种图形,除了保存关键属性外,还实现一些辅助方法,如点与图形的碰撞检测函数等:
contains(x: number, y: number): boolean {
if (this.width <= 0 || this.height <= 0) { return false; }
if (x >= this.x && x < this.x + this.width) {
if (y >= this.y && y < this.y + this.height) { return true; }
}
return false;
}
复制代码
Pixi 对于曲线图形并没有提供碰撞检测的方法,若需要实现吸附点操作之类的功能只能自定义一些 hitDetect 的方法,或在外面使用isPointInStroke这类 API。
在 Graphics 对象的 geometry 属性中存储缓冲区中使用的几何数据,在 drawShape 时会将图形数据及样式属性打包成 GraphicsData 对象添加到当前的图形数组中,用于之后的实际绘制。
drawShape(shape, fillStyle, lineStyle, matrix)
{
const data = new GraphicsData(shape, fillStyle, lineStyle, matrix);
this.graphicsData.push(data);
this.dirty++;
return this;
}
复制代码
在绘制 (更新 batch 指令、执行填充) 时,会计算图形的顶点位置并将三角化后的顶点数据及索引添加到 Geometry 对象的顶点数组中。
build() {
points.push(x, y,
x + width, y,
x + width, y + height,
x, y + height);
}
triangulate() {
const vertPos = verts.length / 2;
verts.push(points[0], points[1],
points[2], points[3],
points[6], points[7],
points[4], points[5]);
graphicsGeometry.indices.push(vertPos, vertPos + 1, vertPos + 2,
vertPos + 1, vertPos + 2, vertPos + 3);
}
复制代码
Graphic 在执行渲染时会通过图形的 batchable 属性来决定是使用 BatchRender 还是 DirectRender 的方式:
_render(renderer) {
this.finishPoly();
const geometry = this.geometry;
geometry.updateBatches();
if (geometry.batchable) {
if (this.batchDirty !== geometry.batchDirty) {
this._populateBatches();
}
this._renderBatched(renderer);
} else {
renderer.batch.flush();
this._renderDirect(renderer);
}
}
复制代码
其中 BatchRender 与精灵中渲染的方式类似,均为调用 BatchSystem 执行绘制,在之前需要一些顶点与索引计算等工作。DirectRender 中也比较简单,设置了渲染着色器,执行 geometry 中存储的 drawCalls 渲染指令。
_renderDirect(renderer) {
uniforms.translationMatrix = this.transform.worldTransform;
uniforms.tint[0] = (((tint >> 16) & 0xFF) / 255) * worldAlpha;
uniforms.tint[1] = (((tint >> 8) & 0xFF) / 255) * worldAlpha;
uniforms.tint[2] = ((tint & 0xFF) / 255) * worldAlpha;
uniforms.tint[3] = worldAlpha;
renderer.shader.bind(shader);
renderer.geometry.bind(geometry, shader);
renderer.state.set(this.state);
for (let i = 0, l = drawCalls.length; i < l; i++) {
this._renderDrawCallDirect(renderer, geometry.drawCalls[i]);
}
}
复制代码
资源加载器与纹理
资源加载器 - Loader
Pixi 的应用场景中多数都需要加载图像或音频资源,如其他游戏框架一样,因此具有专门的 Loader 工具对资源进行处理。
Pixi 中使用了resource-loader这个库来在内部处理资源加载,将其封装为通用的资源加载类 Loader 及纹理加载类 TextureLoader。
在 TextureLoader 中只做了一件事,在加载完成的回调中判断若资源为 Image 类型,则通过 resource 生成 Texture 对象并添加到 texture 属性
export class TextureLoader {
static use(resource, next) {
if (resource.data && resource.type === Resource.TYPE.IMAGE) {
resource.texture = Texture.fromLoader(
resource.data,
resource.url,
resource.name
);
}
next();
}
}
复制代码
接下来看看其中重要的表示所展示图像的 Texture 对象是什么。
纹理 - Texture
纹理为精灵对象提供渲染的图像数据,支持多种图像数据类型。
当通过如下方法创建精灵时:
const bunny = PIXI.Sprite.from('examples/assets/bunny.png');
复制代码
在内部执行了:
from(source, options) {
const texture = (source instanceof Texture)
? source
: Texture.from(source, options);
return new Sprite(texture);
}
from(source, options = {}, strict = settings.STRICT_TEXTURE_CACHE) {
texture = new Texture(new BaseTexture(source, options));
texture.baseTexture.cacheId = cacheId;
BaseTexture.addToCache(texture.baseTexture, cacheId);
Texture.addToCache(texture, cacheId);
}
复制代码
可以看出在精灵的 from 中实际调用了 Texture 的 from 方法用来解析与生成纹理。
在 BaseTexture 中会根据传入的 source 自动判断该资源的类型 (autoDetectResource),判断是否为 SVG、Canvas、Buffer 等资源类型,若经过 test 后该 source 的特征均不满足这些类型,则作为 Image 类型加载,关键部分如下:
autoDetectResource(source, options) {
for (let i = INSTALLED.length - 1; i >= 0; --i) {
const ResourcePlugin = INSTALLED[i];
if (ResourcePlugin.test && ResourcePlugin.test(source, extension)) {
return new ResourcePlugin(source, options);
}
}
return new ImageResource(source, options);
}
复制代码
ImageResource 中会使用 ImageElement 对象来加载图片。
外层的 Texture 类中则
变换、交互及动画
说完基础元素及资源处理,就到了与实际展示或操作有关的变换、交互及动画部分了。
变换处理
packages/interaction/Matrix & Transform
为了高效,采用一维数组的格式保存变换矩阵,使用 math 库中的 Matrix 和 Transform 的组合实现变换数据的相关操作。
Pixi 并没有为精灵提供显式调用的变换相关方法 (rotate, translate, scale),仅能通过直接改变变换属性来实现变换,这些变换属性位于 DisplayObject 类中,即 Container 和 Sprite 的父类。
可以看看这个例子,通过改变精灵的 rotation 属性来控制旋转
app.ticker.add((delta) => {
bunny.rotation += 0.1 * delta;
});
复制代码
改变属性后执行的流程
- Sprite
set rotation(value) { this.transform.rotation = value; } 复制代码
Transform
set rotation(value) { if (this._rotation !== value) { this._rotation = value; this.updateSkew(); } } protected updateSkew(): void { this._cx = Math.cos(this._rotation + this.skew.y); this._sx = Math.sin(this._rotation + this.skew.y); this._cy = -Math.sin(this._rotation - this.skew.x); this._sy = Math.cos(this._rotation - this.skew.x); } 复制代码
交互处理
packages/interaction/src/InteractionManager
默认情况下,负责交互事件的 InteractionManager(以下简称 IManager) 是作为一个插件加载到 renderer 上。
- IManager 负责处理 mouse、touch 与 pointer 事件,
- 当 DisplayObject 的 interactive 属性为 true 时会加入到 IManager 的检测对象中
Manager 在初始化时在 renderer 的 view 属性对应的元素上一股脑的绑定了相关事件的事件监听函数:
var element = this.renderer.view;
this.interactionDOMElement = element;
if (this.supportsPointerEvents) {
window.document.addEventListener('pointermove', this.onPointerMove, true);
this.interactionDOMElement.addEventListener('pointerdown', this.onPointerDown, true);
this.interactionDOMElement.addEventListener('pointerleave', this.onPointerOut, true);
this.interactionDOMElement.addEventListener('pointerover', this.onPointerOver, true);
window.addEventListener('pointercancel', this.onPointerCancel, true);
window.addEventListener('pointerup', this.onPointerUp, true);
} else {
复制代码
这里相比较的话还是 Konva 的绑定事件监听的方式较为科学,Konva 考虑到了不同事件触发的次序来对事件与监听函数进行绑定,而不是单纯在某一时间点统一的绑定与移除。
IManager 在监听交互事件时除了触发相关事件外,还会在内部的 DisplayObject 上执行目标检测与事件分发:
processInteractive(interactionEvent, displayObject, func, hitTest) {
const hit = this.search.findHit(interactionEvent, displayObject, func, hitTest);
const delayedEvents = this.delayedEvents;
if (!delayedEvents.length) { return hit; }
interactionEvent.stopPropagationHint = false;
const delayedLen = delayedEvents.length;
this.delayedEvents = [];
for (let i = 0; i < delayedLen; i++) {
const { displayObject, eventString, eventData } = delayedEvents[i];
if (eventData.stopsPropagatingAt === displayObject) {
eventData.stopPropagationHint = true;
}
this.dispatchEvent(displayObject, eventString, eventData);
}
return hit;
}
复制代码
其中 findHit 为 TreeSearch 的对象方法,用于执行实际的目标检测与事件分发行为。
目标检测
packages/interaction/src/TreeSearch
TreeSearch 使用recursiveFindHit这个递归函数来在 DisplayObject 上执行目标检测
findHit(interactionEvent, displayObject, func, hitTest) {
this.recursiveFindHit(interactionEvent, displayObject, func, hitTest, false);
}
recursiveFindHit(interactionEvent, displayObject, func, hitTest, interactive) {
if (displayObject.hitArea) {
if (hitTest) {
displayObject.worldTransform.applyInverse(point, this._tempPoint);
if (!displayObject.hitArea.contains(this._tempPoint.x, this._tempPoint.y)) {
hitTest = false;
hitTestChildren = false;
} else {
hit = true;
}
}
interactiveParent = false;
} else if (displayObject._mask) {
if (hitTest) {
if (!(displayObject._mask.containsPoint && displayObject._mask.containsPoint(point))) {
hitTest = false;
}
}
}
if (hitTestChildren && displayObject.interactiveChildren && displayObject.children) {
const children = displayObject.children;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
const childHit = this.recursiveFindHit(interactionEvent, child, func, hitTest, interactiveParent);
if (childHit)
{
if (!child.parent) { continue; }
interactiveParent = false;
if (childHit) {
if (interactionEvent.target) {
hitTest = false;
}
hit = true;
}
}
}
}
if (interactive) {
if (hitTest && !interactionEvent.target) {
if (!displayObject.hitArea && displayObject.containsPoint) {
if (displayObject.containsPoint(point))
{
hit = true;
}
}
}
if (displayObject.interactive) {
if (hit && !interactionEvent.target) {
interactionEvent.target = displayObject;
}
if (func) {
func(interactionEvent, displayObject, !!hit);
}
}
}
return hit;
}
复制代码
Ticker 与 rAF 动画
packages/ticker
动画是 Pixi 中比较重要的一个模块,它将 rAF 动画封装成了一个 Ticker 类,主要有如下三个特性:
- 可控制的 rAF 动画运行状态:开始与停止
- 灵活的 MainLoop 任务管理:分离了执行任务,可以根据需要单独在 Ticker 对象上添加或移除在帧动画中执行的任务
- 可自定义的执行频率:可以通过设置指定的最大与最小 FPS 值,内部经过执行时间差的计算判断是否在下一帧执行后续任务
通常我们执行 rAF 动画时都是简单的递归调用,如下:
function render() {
work();
requestAnimationFrame(render);
}
复制代码
使用 Ticker 操作帧动画的执行函数:
let numA = 0;
let numB = 0;
const renderTaskInit = () => { initWork() }
const renderTaskA = () => { renderWork() }
const renderTaskB = () => { renderWork() }
app.ticker.addOnce()
app.ticker.add(renderTaskA);
app.ticker.add(renderTaskB, this);
app.ticker.remove(renderTaskA)
复制代码
Ticker 的原理
内部实现主要由 Ticker 与 TickerListener 这两个类组成。
1. 动画开始与停止的控制
start(): void {
if (!this.started) {
this.started = true;
this._requestIfNeeded();
}
}
private _requestIfNeeded(): void {
if (this._requestId === null && this._head.next) {
this.lastTime = performance.now();
this._lastFrame = this.lastTime;
this._requestId = requestAnimationFrame(this._tick);
}
}
stop(): void {
if (this.started) {
this.started = false;
this._cancelIfNeeded();
}
}
private _cancelIfNeeded(): void {
if (this._requestId !== null) {
cancelAnimationFrame(this._requestId);
this._requestId = null;
}
}
复制代码
2.MainLoop 中的任务管理
Ticker 类的对象在初始化时会创建_ticker 来执行 rAF 的递归:
this._tick = (time: number): void =>{
this._requestId = null;
if (this.started) {
this.update(time);
if (this.started && this._requestId === null && this._head.next)
{
this._requestId = requestAnimationFrame(this._tick);
}
}
};
复制代码
在 update 方法中会遍历一个监听器链表
update(currentTime = performance.now()): void {
const head = this._head;
let listener = head.next;
while (listener) {
listener = listener.emit(this.deltaTime);
}
if (!head.next) {
this._cancelIfNeeded();
}
}
复制代码
其中的 listener 为一个 TickerListener 对象,在这个对象中以链表的结构存储多个监听事件的处理函数,每次 emit 时执行当前函数,并返回 next 值对应的下一个 listener,若 listener 为空则表示执行完毕。
emit(deltaTime: number): TickerListener {
if (this.fn) {
if (this.context) {
this.fn.call(this.context, deltaTime);
} else {
(this as TickerListener<any>).fn(deltaTime);
}
}
const redirect = this.next;
return redirect;
}
复制代码
3. 控制任务执行频率
当设置最大 FPS 时,会计算每秒内帧之间的最短间隔:
set maxFPS(fps) {
if (fps === 0){
this._minElapsedMS = 0;
} else {
const maxFPS = Math.max(this.minFPS, fps);
this._minElapsedMS = 1 / (maxFPS / 1000);
}
}
复制代码
则在 update() 方法中会根据这个时间判断是否在这一帧内执行后续任务:
update(currentTime = performance.now()): void {
if (this._minElapsedMS) {
const delta = currentTime - this._lastFrame | 0;
if (delta < this._minElapsedMS) {
return;
}
this._lastFrame = currentTime - (delta % this._minElapsedMS);
}
}
复制代码
总结
可以看出,Pixi 实现了高性能 2D 渲染的目标,背后的付出则是大量额外实现的 WebGL 图形绘制 (贝塞尔曲线、基础图形等) 与辅助方法 (碰撞检测) 的代码,并且针对动画与资源加载也做了许多优化和额外的功能,不失为一个优秀的框架。
