Canvas2D渲染库简析:(三)Pixi

4,274 阅读14分钟

fabric和konva主要是用于实现编辑器的场景,而Pixi则是一个高性能2D动画渲染库,通常用于一些H5的小游戏或可交互页面。

本次通过以下几个方面来对其进行分析:

  • WebGL与Canvas渲染器
  • 资源加载器与纹理
  • 场景、精灵与图形对象
  • 变换、交互及动画处理

系列目录

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) {
    // 1. 应用变换(GPU级别)
    this.projection.transform = transform;
    // 2. 渲染纹理绑定与BatchRendering处理
    this.renderTexture.bind(renderTexture);
    this.batch.currentRenderer.start();
    // 3. 执行元素渲染,将顶点、索引和纹理等数据添加到BatchRendering中
    displayObject.render();
    // 4. 执行renderer的绘制方法
    this.batch.currentRenderer.flush();
    // 根据传入的clear与renderTexture参数对纹理的处理...
    // 5. 清空变换
    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;
    // 1. 当前状态压入状态栈
    context.save();
    // 2. 初始化变换及样式属性
    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];
    // 3.执行元素渲染
    const tempContext = this.context;
    this.context = context;
    displayObject.renderCanvas(this);
    this.context = tempContext;
    // 4. 从状态栈恢复之前状态
    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;
    // 1. 解析变换矩阵
    const wt = this.transform.worldTransform;
    const tx = wt.tx;
    // ...
    // 2. 计算当前区域
    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;

    // 3. 计算通过世界变换后的四个顶点坐标
    vertexData[0] = (a * w1) + (c * h1) + tx;
    vertexData[1] = (d * h1) + (b * w1) + ty;
    // ...
}

判断点是否在该精灵的区域中

containsPoint(point) {
    // 1. 在世界空间上应用逆变换得到模型空间坐标
    this.worldTransform.applyInverse(point, tempPoint);
    // 2. 通过纹理与锚点计算精灵几何属性
    const width = this._texture.orig.width;
    const height = this._texture.orig.height;
    const x1 = -width * this.anchor.x;
    let y1 = 0;
    // 3. 判断是否位于对象区域
    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)。

// 通过修改该pluginName属性设置负责渲染该精灵的插件
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类。

// packages/graphics/src/Graphics.js
drawRect(x, y, width, height) {
    return this.drawShape(new Rectangle(x, y, width, height));
}

对于每种图形,除了保存关键属性外,还实现一些辅助方法,如点与图形的碰撞检测函数等:

// packages/math/src/shapes/Rectangle.ts
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对象添加到当前的图形数组中,用于之后的实际绘制。

// packages/graphics/src/GraphicsGeometry.js
drawShape(shape, fillStyle, lineStyle, matrix)
{
    const data = new GraphicsData(shape, fillStyle, lineStyle, matrix);
    this.graphicsData.push(data);
    this.dirty++;
    return this;
}

在绘制(更新batch指令、执行填充)时,会计算图形的顶点位置并将三角化后的顶点数据及索引添加到Geometry对象的顶点数组中。

// packages/graphics/src/utils/buildRectangle
// 1. 顶点坐标计算
build() {
  points.push(x, y,
    x + width, y,
    x + width, y + height,
    x, y + height);
}

// 2. 图形三角化,插入顶点数据及三角形顶点索引,用于之后绘制
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) {
    // 多边形对象绘制(本质是PathDrawing)
    this.finishPoly();
    // 读取geometry,生成batch数据
    const geometry = this.geometry;
    geometry.updateBatches();
    // 执行渲染
    if (geometry.batchable) {
        // 判断batch数据是否需要更新
        if (this.batchDirty !== geometry.batchDirty) {
            this._populateBatches();
        }
        // 执行BatchRender
        this._renderBatched(renderer);
    } else {
        renderer.batch.flush();
        // 执行DirectRender
        this._renderDirect(renderer);
    }
}

其中BatchRender与精灵中渲染的方式类似,均为调用BatchSystem执行绘制,在之前需要一些顶点与索引计算等工作。DirectRender中也比较简单,设置了渲染着色器,执行geometry中存储的drawCalls渲染指令。

_renderDirect(renderer) {
    // 设置uniform
    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');

在内部执行了:

// packages/sprite/src/Sprite
from(source, options) {
    const texture = (source instanceof Texture)
        ? source
        : Texture.from(source, options);

    return new Sprite(texture);
}
// packages/core/src/textures/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;
});

改变属性后执行的流程

  1. Sprite

    set rotation(value) {
        this.transform.rotation = value;
    }
    
  2. Transform

    set rotation(value) {
        if (this._rotation !== value)
        {
            this._rotation = value;
            this.updateSkew();
        }
    }
    protected updateSkew(): void {
        // 计算变换矩阵中scale与skew参数
        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); // cos, added PI/2
        this._sy = Math.cos(this._rotation - this.skew.x); // sin, added PI/2
    }
    

交互处理

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) {
    // 目标检测,并向内部的interactive DisplayObject分发事件
    const hit = this.search.findHit(interactionEvent, displayObject, func, hitTest);
    // 处理延迟事件,当多个mouse/pointer事件触发时
    const delayedEvents = this.delayedEvents;
    if (!delayedEvents.length) { return hit; }
    // 重置hint,为了在tree中继续搜索
    interactionEvent.stopPropagationHint = false;
    const delayedLen = delayedEvents.length;
    this.delayedEvents = [];
    // 向DisplayObjects分发事件
    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) {
    // 1. hitArea与mask判断
    if (displayObject.hitArea) {
        // 若存在hitArea,通过contains判断该点是否在模型空间的目标区域内
        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) {
        // 若存在mask,通过contains判断该点是否在mask区域内
        if (hitTest) {
            if (!(displayObject._mask.containsPoint && displayObject._mask.containsPoint(point))) {
                hitTest = false;
            }
        }
    }
    // 2. 执行递归函数检测子元素的碰撞情况
    if (hitTestChildren && displayObject.interactiveChildren && displayObject.children) {
        const children = displayObject.children;
        for (let i = children.length - 1; i >= 0; i--) {
            const child = children[i];
            // 递归调用,若为true说明检测到碰撞对象
            const childHit = this.recursiveFindHit(interactionEvent, child, func, hitTest, interactiveParent);
            if (childHit)
            {
                // 若当前子元素的父辈被移除,则跳过检测
                if (!child.parent) { continue; }
                interactiveParent = false;
                // PS: 这里的if(childHit)检测是多余的?
                if (childHit) {
                    if (interactionEvent.target) {
                        hitTest = false;
                    }
                    hit = true;
                }
            }
        }
    }
    // 3. 执行目标检测
    if (interactive) {
        if (hitTest && !interactionEvent.target) {
            // 之前检测过hitArea,这里不再处理
            if (!displayObject.hitArea && displayObject.containsPoint) {
                if (displayObject.containsPoint(point))
                {
                    hit = true;
                }
            }
        }
        // 若该元素interactive为true,则设置为当前事件的target,并执行传入的回调函数
        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类,主要有如下三个特性:

  1. 可控制的rAF动画运行状态:开始与停止
  2. 灵活的MainLoop任务管理:分离了执行任务,可以根据需要单独在Ticker对象上添加或移除在帧动画中执行的任务
  3. 可自定义的执行频率:可以通过设置指定的最大与最小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); // 循环执行的任务,可传入context对象
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图形绘制(贝塞尔曲线、基础图形等)与辅助方法(碰撞检测)的代码,并且针对动画与资源加载也做了许多优化和额外的功能,不失为一个优秀的框架。

参考