leaferjs,全新的 Canvas 渲染引擎
liuian 2024-12-18 15:36 44 浏览
1. 前言
前几天群里有人发了一个信 Canvas 渲染引擎的图片,看数据和宣传口号相当炸裂,号称只用 1.5s 可以渲染 100 万个矩形,还是个国产的。
出于个人兴趣,就花了一点儿时间研究了一下感兴趣的点,如果有错误,希望可以指出。
2. 架构设计
从火焰图上可以看出,leaferjs 创建节点非常轻量,只做了 setAttr 的操作。
大部分耗时集中在创建节点和布局,渲染仅仅花了3ms。
那 leaferjs 为什么有这么好的性能呢?我简单去看了一下源码。
leafer 主要包括了 leafer 和 ui 两个 git 仓库,核心渲染能力在 leafer 里面,ui 封装了一些绘制类,比如 Image、Line 等等。
先看一下官网上一个详细的用法:
php复制代码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 作为切入点,发现上面挂了很多装饰器。
java复制代码@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.defineProperty 对 set 做了拦截,底层调用了 __setAttr 方法。
其中节点的一些信息都挂在 __ 上面,比如 fill、shadow 等等。
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 这个类,它的实现非常简单。
kotlin复制代码@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)
}
}
}
这里做了下面几件事:
- 使用装饰器 useModule 来将 RectRender 类的方法混合到 Rect 上面。
- 继承了 UI 类。
- 实现了 __drawPathByData 方法,看起来是绘制方法。
先来看一下 RectRender 里面做了什么,发现它只实现了一个 __drawFast 方法,那这个 __drawFast 和 __drawPathByData 有什么区别呢?
搜索了一下调用方,发现两者的区别在于当前绘制类是否有 __complex,如果是复杂的,就走 __drawPathByData,否则就走 __drawFast。
那什么是复杂,什么是简单呢?以官网 Demo 为例子,当 fill 不是字符串的时候就算是复杂绘制。复杂绘制去尝试去解析 fill、stroke 等属性,最后才调用 __drawPathByData。
Rect 继承的 UI 类封装了绘制方法的调用,以及 fill、stroke、x、y 等属性。
UI 类又继承了 Leaf 类,Leaf 是最底层的类,混合了一系列底层能力。
LeafMatrix 定义了矩阵变换的信息,LeafBounds 定义了包围盒的信息,LeafEventer 提供了事件的监听、取消监听等方法。
LeafDataProxy 提供了 get/set 的能力,前面 UI 类定义的时候通过 @opcityType、@positionType 等装饰器拦截了属性的 set。
因此当我们每次修改属性的时候,就会触发到这里的 __setAttr 方法。
kotlin复制代码
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 方法里面有一系列判断,核心点在于 fullRender 和 partRender 两个地方。
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 的例子:
arduino复制代码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 清空这片区域。
接着对节点进行遍历,遍历后的结果是 circle1、rect2、circle2、rect3 是需要重绘的,就会调用这些节点的 __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 最终调用到了 FindPath 的 eachFind 里面。
在 eachFind 里面会遍历当前 Leafer 的子节点,子节点可能是个 Branch(Group),也可能是个 Leaf。
如果是个 Branch 的话,就先通过 hitRadiusPoint 来判断是否 hit 了当前的 Branch,如果命中了,那就继续递归它的子节点。
如果不是个 Branch,那么就是个普通 Leaf 节点,直接调用 __hitWorld 方法来判断 point 是否命中当前的 Leaf 节点。
__hitWorld 的原理是:
- 在离屏的一个 hitCanvas 里面将当前节点绘制一遍。
- 调用 isPointInPath 或者 isPointInStroke 来判断是否打击到了。
为什么这里要利用 isPointInPath 呢?
因为在 beginPath 之后,绘制的路径都会被添加到这个路径集合里,isPointInPath(x, y) 方法判断的就是x、y 点是否在这个路径集合的所有路径里。
画一个流程图来梳理一下事件拾取:
所以对于不规则图形来说,通过 isPointInPath 也可以简单的判断是否命中,不需要自己去写复杂的几何碰撞算法。
很显然 isPointInPath 也有缺点,那就是同样需要绘制两遍,一个是主画布,一个是 hitCanvas。
相比 Konva 在首屏就绘制了两遍,leaferjs 会在事件触发的时候,针对当前遍历的节点进行 hitCanvas 的绘制,所以首屏渲染性能比 Konva 要好很多。但这部分绘制只是延迟了,最终还是要两份的。
但由于不需要去存 colorKey 这些数据,内存占用相比 Konva 还是会少了很多。
5. 总结
leaferjs 是一个国人在工作之余写的渲染库,看文件目录未来还会支持 Canvaskit、Miniapp,也支持开发者贡献插件,野心不小。
虽然处于刚起步阶段,相信随着后续迭代,leaferjs 会变成一个非常具有竞争力的 Canvas 库。
相关推荐
- 10种常见的MySQL错误,你可中招?
-
【51CTO.com快译】如果未能对MySQL8进行恰当的配置,您非但可能遇到无法顺利访问、或调用MySQL的窘境,而且还可能给真实的应用生产环境带来巨大的影响。本文列举了十种MySQL...
- MySQL主从如何保证数据一致性
-
MySQL主从(主备)搭建请点击基于Spring的数据库读写分离。MySQL主备基本原理假设主备切换前,我们的主库是节点A,节点B是节点A的备库,客户端的读写都是直接访问节点A,节点B只是将A的更新同...
- MySQL低版本升级操作流程
-
(关注“数据库架构师”公众号,提升数据库技能,助力职业发展)0-升级背景MySQL5.5发布于2010年,至今已有十年历史,官方已经停止更新。2008年发布的MySQL5.1版本,在2018年...
- MySQL数据库知识
-
MySQL是一种关系型数据库管理系统;那废话不多说,直接上自己以前学习整理文档:查看数据库命令:(1).查看存储过程状态:showprocedurestatus;(2).显示系统变量:show...
- Mysql 8.4数据库安装、新建用户和数据库、表单
-
1、下载MySQL数据库yuminstall-ywgetperlnet-toolslibtirpc#安装wget和perl、net-tools、libtirpcwgethtt...
- mysql8.0新功能介绍
-
MySQL8.0新特性集锦一、默认字符集由latin1变为utf8mb4在8.0版本之前,默认字符集为latin1,utf8指向的是utf8mb3,8.0版本默认字符集为utf8mb4,utf8默...
- 全网最详细解决Windows下Mysql数据库安装后忘记初始root 密码方法
-
一、准备重置root的初始化密码Win+R键启动命令输入窗口;输入cmd打开命令执行窗口;##界面如下##输入命令:netstopmysqld#此操作会停止当前运行的...
- 互联网大厂面试:MySQL使用grant授权后必须flush privilege吗
-
从我上大学时,数据库概论老师就告诉我,MySQL使用grant对用户授权之后,一定记得要用flushprivilege命令刷新缓存,这样才能使赋权命令生效。毕业工作以后,在很多的技术文档上,仍然可以...
- # mysql 8.0 版本无法使用 sqlyog 等图形界面 登录 的解决方法
-
30万以下的理想L6来了##mysql8.0版本无法使用sqlyog等图形界面登录的解决方法当我们在cmd下登录mysql时正常时,用sqlyog等图形界面连接数据库时却...
- MySQL触发器介绍
-
前言:在学习MySQL的过程中,可能你了解过触发器的概念,不清楚各位是否有详细的去学习过触发器,最近看了几篇关于触发器的文档,分享下MySQL触发器相关知识。1.触发器简介触发器即trigg...
- 管理员常用的MySQL命令汇总(一)
-
以下是管理员常用的MySQL命令:以管理员身份连接到MySQL:mysql-uroot-p创建新的MySQL用户:CREATEUSER'username'@'...
- Linux(CentOS) 在线安装MySQL8.0和其他版本,修改root密码
-
一:安装MySQL数据库1),下载并安装MySQL官方的YumRepositorymysql官方仓库地址:https://dev.mysql.com/downloads/repo/yum/选择自...
- 解决 MySQL 8.0 一直拒绝 root 登录问题
-
Accessdeniedforuser'root'@'localhost'(usingpassword:YES)这个错误在网上搜一下,能看到非常多的此类...
- 大模型MCP之MYSQL安装
-
前言学习大模型的时候需要一个mysql,原因还是在公司使用电脑的时候不允许按照Docker-Desktop,我的宿主机其实是MAC,我习惯上还是在centsos上面安装,就发现这件过去很简单的事情居然...
- MySQL ERROR 1396
-
ERROR1396(HY000):OperationCREATEUSERfailedfor'usera'@'%'问题描述mysql>create...
- 一周热门
-
-
Python实现人事自动打卡,再也不会被批评
-
【验证码逆向专栏】vaptcha 手势验证码逆向分析
-
Psutil + Flask + Pyecharts + Bootstrap 开发动态可视化系统监控
-
一个解决支持HTML/CSS/JS网页转PDF(高质量)的终极解决方案
-
再见Swagger UI 国人开源了一款超好用的 API 文档生成框架,真香
-
网页转成pdf文件的经验分享 网页转成pdf文件的经验分享怎么弄
-
C++ std::vector 简介
-
系统C盘清理:微信PC端文件清理,扩大C盘可用空间步骤
-
飞牛OS入门安装遇到问题,如何解决?
-
10款高性能NAS丨双十一必看,轻松搞定虚拟机、Docker、软路由
-
- 最近发表
- 标签列表
-
- python判断字典是否为空 (50)
- crontab每周一执行 (48)
- aes和des区别 (43)
- bash脚本和shell脚本的区别 (35)
- canvas库 (33)
- dataframe筛选满足条件的行 (35)
- gitlab日志 (33)
- lua xpcall (36)
- blob转json (33)
- python判断是否在列表中 (34)
- python html转pdf (36)
- 安装指定版本npm (37)
- idea搜索jar包内容 (33)
- css鼠标悬停出现隐藏的文字 (34)
- linux nacos启动命令 (33)
- gitlab 日志 (36)
- adb pull (37)
- python判断元素在不在列表里 (34)
- python 字典删除元素 (34)
- vscode切换git分支 (35)
- python bytes转16进制 (35)
- grep前后几行 (34)
- hashmap转list (35)
- c++ 字符串查找 (35)
- mysql刷新权限 (34)