9

Canvas2D渲染库简析:(三)Pixi | ¥ЯႭ1I0

 4 years ago
source link: https://yrq110.me/post/front-end/dive-into-2d-canvas-framework-iii-pixi/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Canvas2D渲染库简析:(三)Pixi

2019年12月30日

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

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

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

系列文章

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图形绘制(贝塞尔曲线、基础图形等)与辅助方法(碰撞检测)的代码,并且针对动画与资源加载也做了许多优化和额外的功能,不失为一个优秀的框架。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK