yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
262 stars 12 forks source link

Leaferjs,全新的 Canvas 渲染引擎 #85

Open yinguangyao opened 1 year ago

yinguangyao commented 1 year ago

1. 前言

前几天群里有人发了一个新 Canvas 渲染引擎的图片,看数据和宣传口号相当炸裂,号称只用 1.5s 可以渲染 100 万个矩形,还是个国产的。

出于个人兴趣,就花了一点儿时间研究了一下感兴趣的点,如果有错误,希望可以指出。

2. 架构设计

从火焰图上可以看出,leaferjs 创建节点非常轻量,只做了 setAttr 的操作。

大部分耗时集中在创建节点和布局,渲染仅仅花了3ms。

那 leaferjs 为什么有这么好的性能呢?我简单去看了一下源码。

leafer 主要包括了 leafer 和 ui 两个 git 仓库,核心渲染能力在 leafer 里面,ui 封装了一些绘制类,比如 Image、Line 等等。

先看一下官网上一个详细的用法:

import { App, Leafer, Rect } from 'leafer-ui'

const app = new App({ view: window, type: 'user' }) 

const backgroundLayer = new Leafer()
const contentLayer = new Leafer({ type: 'design' })
const wireframeLayer = new Leafer()

app.add(backgroundLayer)
app.add(contentLayer)
app.add(wireframeLayer)

const background = new Rect({ width: 800, height: 600, fill: 'gray' })
const rect = new Rect({ x: 100, y: 100, fill: '#32cd79', draggable: true })
const border = new Rect({ x: 200, y: 200, stroke: 'blue', draggable: true })

backgroundLayer.add(background)
contentLayer.add(rect)
wireframeLayer.add(border)

从 Demo 可以看到 App 作为一个应用的实例,能往里面添加 Leafer 实例,每个 Leafer 内部会创建一个 Canvas 节点,这个和 Konva 的 Layer 比较相似。

通过创建多个 Leafer,可以来做 Canvas 分层优化。

每个 leafer 作为一个容器,可以里面去添加子节点,比如 rect 等等。

2.1 Leafer

从 Leafer 作为切入点,发现上面挂了很多装饰器。

@registerUI()
export class Leafer extends Group implements ILeafer {

    public get __tag() { return 'Leafer' }

    @dataProcessor(LeaferData)
    public __: ILeaferData

    @boundsType()
    public pixelRatio: number

    public get isApp(): boolean { return false }

    public parent?: App
}

其中 registerUI 的用来注册当前的 Leafer 类的,会将其放入一个 UICreator.list 里面,后续可以使用 Leafer.one(data) 的形式来创建。

__tag 是用于标识当前节点的类型,比如 'Leafer'、'Rect' 等等。

boundsType 装饰器里面通过 Object.definePropertyset 做了拦截,底层调用了 __setAttr 方法。

其中节点的一些信息都挂在 __ 上面,比如 fillshadow 等等。

interaction 模块是用于处理事件监听的,它会监听 DOM 事件,将其再次分发给节点。

canvasManager 是用于管理 Canvas 节点的,可以理解为一个 Canvas 池,支持创建、销毁 Canvas 节点,也支持复用相同尺寸的 Canvas 节点。

imageManager 是用于管理图片资源的下载、获取的模块。

init 方法中,会根据传给 Leafer 的 config 信息创建一个新的 Canvas 节点,前提是你有设置 view 属性,所以 leaferjs 支持 Canvas 分层管理。

Creator 提供了一系列创建方法,其中 renderer 是创建了一个渲染器,里面封装了 Canvas 渲染的核心机制。

这里还调用 Creator.watcher 来创建一个 Watcher 实例,Watcher 观察节点的属性变化,从而触发重新渲染。

Creator.selector 创建了一个选择器,主要用于根据坐标点去查询对应的 Branch 分支。

Leafer 继承了 Group 类,Group 又 mixin 了 Branch 类,所以在 leaferjs 里面,所以容器类都是继承了 Group 和 Branch。

2.2 Leaf

那创建完成后,形状又是怎么绘制的呢?我们来看一下 Rect 这个类,它的实现非常简单。

@useModule(RectRender)
@registerUI()
export class Rect extends UI implements IRect {

    public get __tag() { return 'Rect' }

    @dataProcessor(RectData)
    public __: IRectData

    constructor(data?: IRectInputData) {
        super(data)
    }

    public __drawPathByData(drawer: IPathDrawer, _data: IPathCommandData): void {
        const { width, height, cornerRadius } = this.__
        if (cornerRadius) {
            drawer.roundRect(0, 0, width, height, cornerRadius)
        } else {
            drawer.rect(0, 0, width, height)
        }
    }
}

这里做了下面几件事:

  1. 使用装饰器 useModule 来将 RectRender 类的方法混合到 Rect 上面。
  2. 继承了 UI 类。
  3. 实现了 __drawPathByData 方法,看起来是绘制方法。

先来看一下 RectRender 里面做了什么,发现它只实现了一个 __drawFast 方法,那这个 __drawFast__drawPathByData 有什么区别呢?

搜索了一下调用方,发现两者的区别在于当前绘制类是否有 __complex,如果是复杂的,就走 __drawPathByData,否则就走 __drawFast

那什么是复杂,什么是简单呢?以官网 Demo 为例子,当 fill 不是字符串的时候就算是复杂绘制。复杂绘制去尝试去解析 fillstroke 等属性,最后才调用 __drawPathByData

Rect 继承的 UI 类封装了绘制方法的调用,以及 fillstrokexy 等属性。

UI 类又继承了 Leaf 类,Leaf 是最底层的类,混合了一系列底层能力。

LeafMatrix 定义了矩阵变换的信息,LeafBounds 定义了包围盒的信息,LeafEventer 提供了事件的监听、取消监听等方法。

LeafDataProxy 提供了 get/set 的能力,前面 UI 类定义的时候通过 @opcityType@positionType 等装饰器拦截了属性的 set

因此当我们每次修改属性的时候,就会触发到这里的 __setAttr 方法。


export const LeafDataProxy: ILeafDataProxyModule = {

    __setAttr(name: string, newValue: unknown): void {
        if (this.leafer && this.leafer.ready) {
            this.__[name] = newValue
            const { CHANGE } = PropertyEvent
            const event = new PropertyEvent(CHANGE, this, name, this.__.__get(name), newValue)
            if (this.hasEvent(CHANGE) && !this.isLeafer) this.emitEvent(event)
            this.leafer.emitEvent(event)
        } else {
            this.__[name] = newValue
        }
    },

    __getAttr(name: string): unknown {
        return this.__.__get(name)
    }

}

3. 更新机制

前面的 __setAttr 方法触发时,就会调用 this.emitEvent(CHANGE) 发送一个事件。

事件在前面说的 Watcher 里面监听,会将当前节点放到一个更新队列里面,并发送一个 RenderEvent.REQUEST 事件,开始请求渲染。

请求渲染之后,就会放入一个 requestAnimateFrame 里面进行下一帧渲染,这样做是为了提升性能做批量更新,避免大量属性修改的时候频发触发更新。

这里可以参考一下官网给的渲染生命周期,可以发现是一致的:

render 方法里面有一系列判断,核心点在于 fullRenderpartRender 两个地方。

3.1 可视区域渲染

先来看一下 fullRender 方法,这个是全量渲染,不会去计算最小渲染区域。当初次渲染或者设置了 usePartRender 为 false 的时候就会走全量渲染。

全量渲染会调用到 this.target.__render 里面,这个 target 是指 Leafer,意思就是从 Leafer 根节点开始,往下遍历子节点来渲染。

__render 哪里来的呢?Leafer 继承了 Group,Group 又混合了 Branch 的方法,所以 __render 就是 Branch 类上面的。

这里的核心在于下面这句:

遍历渲染的时候,会判断当前 Branch 或者 Leaf 节点是否在给定的 Bound 内(这里的 Bound 就是可视区域,child.__world 是当前节点的位置信息,调用 bounds.hit 方法)。

如果不在可视区域,那就 continue,否则就执行子节点的 __render 方法。

在 Fabric 里面也有这种的优化,Konva 里面反而没有,所以在 leaferjs 给的对比里面,Konva 渲染速度是最低的。

3.2 局部渲染

另一个分支是 partRender 方法,partRender 的实现原理是将每个节点变化前后的包围盒进行一次合并,计算出当前节点的 Block

然后利用 Canvas 的 clip 进行裁剪,再去遍历 Leafer 下面所有的子节点,判断其是否和 Block 相交,如果相交那么就进行重绘。

partRender 的源码如下:

updateBlocks 是这次更新涉及的所有节点的包围盒信息,其中每个节点的包围盒信息都是更新前和更新后的两个包围盒合并后的信息。

举个简单的矩形向右移动 100px 的例子:

const rect = new Rect({ x: 0, y: 0, width: 100, height: 100});

rect.x = 100;

上面这个矩形的位置发生了变化,它在这次更新中的包围盒信息就是 { x: 0, y: 0, width: 200, height }

最关键的点在于 clipRender 里面进行了局部渲染,那么它是怎么做的呢?

其实本质上还是复用了前面 fullRender 里面判断节点和 Bounds 是否相交,如果相交的话,这个节点就会进行重绘。

使用下面这个例子来讲解会更容易理解一些:

rect2 移动到了下方,虚线框就是要重绘的区域,在重绘之前先对这片区域进行 clip,然后 clear 清空这片区域。

接着对节点进行遍历,遍历后的结果是 circle1rect2circle2rect3 是需要重绘的,就会调用这些节点的 __render 方法进行重绘。

这里为什么 rect4 没有被重绘呢?虽然它和 circle2 相交了,但由于提前进行了一次 clip,因此 circle2 的重绘不会影响到 rect4。

使用局部渲染,可以避免每次节点的修改都会触发整个画布的重绘,降低绘制的开销。

但由于 hit 计算也有一定的 cpu 开销,对于一些修改影响范围大的场景,性能可能反而不如全量渲染。

4. 事件拾取

事件拾取也是 Canvas 渲染引擎里面的一个核心功能,一般来说 Canvas 在 DOM 树里面的表现只是一个节点,里面的形状都是自己绘制的,因此我们无法感知到用户当前触发的是哪个形状。

在 Konva 里面采用了色值法的方式来实现,但色值法开销很大,尤其是绘制带来了两倍开销。

在 leaferjs 里面针对 Konva 的事件拾取做了一定优化。

对事件拾取感兴趣的也可以看一下 Antv/g 语雀上的一篇博客:G 4.0 的拾取方案设计

前面讲过,interaction 模块封装了事件,它将绑定在 Leafer 根节点的 DOM 事件进行了包装和分发,分发给对应的 Leaf 节点。

我们以鼠标的点击事件为例子来讲解,this.selector.getByPoint 就是根据坐标点来匹配 Leaf 节点的方法。

getByPoint 最终调用到了 FindPatheachFind 里面。

eachFind 里面会遍历当前 Leafer 的子节点,子节点可能是个 Branch(Group),也可能是个 Leaf。

如果是个 Branch 的话,就先通过 hitRadiusPoint 来判断是否 hit 了当前的 Branch,如果命中了,那就继续递归它的子节点。

如果不是个 Branch,那么就是个普通 Leaf 节点,直接调用 __hitWorld 方法来判断 point 是否命中当前的 Leaf 节点。

__hitWorld 的原理是:

  1. 在离屏的一个 hitCanvas 里面将当前节点绘制一遍。
  2. 调用 isPointInPath 或者 isPointInStroke 来判断是否打击到了。

为什么这里要利用 isPointInPath 呢?

因为在 beginPath 之后,绘制的路径都会被添加到这个路径集合里,isPointInPath(x, y) 方法判断的就是x、y 点是否在这个路径集合的所有路径里。

画一个流程图来梳理一下事件拾取:

所以对于不规则图形来说,通过 isPointInPath 也可以简单的判断是否命中,不需要自己去写复杂的几何碰撞算法。

很显然 isPointInPath 也有缺点,那就是同样需要绘制两遍,一个是主画布,一个是 hitCanvas

相比 Konva 在首屏就绘制了两遍,leaferjs 会在事件触发的时候,针对当前遍历的节点进行 hitCanvas 的绘制,所以首屏渲染性能比 Konva 要好很多。但这部分绘制只是延迟了,最终还是要两份的。

但由于不需要去存 colorKey 这些数据,内存占用相比 Konva 还是会少了很多。

5. 总结

leaferjs 是一个国人在工作之余写的渲染库,看文件目录未来还会支持 Canvaskit、Miniapp,也支持开发者贡献插件,野心不小。

虽然处于刚起步阶段,相信随着后续迭代,leaferjs 会变成一个非常具有竞争力的 Canvas 库。