leaferjs,全新的 Canvas 渲染引擎
liuian 2024-12-18 15:36 37 浏览
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 库。
相关推荐
- Docker 47 个常见故障的原因和解决方法
-
【作者】曹如熙,具有超过十年的互联网运维及五年以上团队管理经验,多年容器云的运维,尤其在Docker和kubernetes领域非常精通。Docker是一种相对使用较简单的容器,我们可以通过以下几种方式...
- 电脑30个快问快答,解决常见电脑问题
-
1.强行关机/停电对电脑有影响吗?答:可能损坏硬盘(机械硬盘风险高)、未保存数据丢失,偶尔一次影响小,但频繁操作会缩短硬件寿命。2.C盘满影响速度吗?答:会!系统运行需C盘空间缓存临时数据,空间不...
- 使用Tcpdump包抓取分析数据包的详细用法
-
TcpDump可以将网络中传送的数据包的“头”完全截获下来提供分析。它支持针对网络层、协议、主机、网络或端口的过滤,并提供and、or、not等逻辑语句来帮助你去掉无用的信息。tcpdump就是一种...
- 电脑启动不了(BootDevice Not Found Hard Disk-3F0)解决方案
-
HP品牌机,开机启动不了,黑屏,开机取下主板电池恢复BIOS后,开机显示找不到启动盘。一、按F2键进入BIOS,出现硬盘内存检测界面的话,直接退出。就会出现这个界面,光标键向下,选择BIOSSetu...
- 电脑开机黑屏别慌!快码住!起底维修老师傅不能说的秘密
-
按下开机键却只收获黑屏大礼包?那些神秘的英文提示、刺耳的蜂鸣声,其实是电脑在给你发送求救信号!从按下电源到进入桌面的12秒里,你的电脑经历了史诗级的硬件自检与系统加载,今天我们就破译这段“摩斯电码”。...
- 电脑启动故障为何总要先看BIOS?新手必读的关键知识解析
-
最近在帮朋友们解答电脑无法正常开机的问题时,发现大家经常收到一句高频建议:“先检查BIOS”。对不少普通用户而言,BIOS依然是个神秘的存在。那么,BIOS到底是什么?电脑出现哪些故障会与它相关呢?本...
- Windows 11 KB5053598更新:安全补丁还是系统噩梦?
-
2025年3月11日,微软发布了Windows1124H2的强制性更新KB5053598,作为“周二补丁日”(PatchTuesday)的一部分。然而,这款本应提升系统安全性的更新却引发了广泛的...
- 飞牛OS入门安装遇到问题,如何解决?
-
之前小编尝试了用旧电脑装飞牛OS安装之前特意查了一些硬件要求飞牛OS目前支持主流的x86架构硬件主机需能连网线飞牛OS暂时不支持只有无线网卡的安装貌似很多小伙伴在一开始安装就卡住了那今天咱们汇总分...
- 几种常见的电脑开机黑屏显示白色英文字母解决方法
-
当电脑开机出现黑屏并显示白色英文字母时,通常表示系统启动过程中遇到了错误。以下是几种常见原因及对应的解决方法,按照排查顺序整理:一、检查外接设备与硬件连接可能原因:外接U盘、移动硬盘等未拔出,或内部硬...
- 电脑启动出现问题,为什么都要先检查BIOS?
-
【ZOL中关村在线原创技巧应用】最近在回答问题的时候,总会发现很多朋友都在问“电脑无法正常开机怎么办?”这样类似的问题,而许多DIY大佬的回复总会出现一条高频建议“先检查BIOS”。但对于许多普通用户...
- 教你怎么用JavaScript检测当前浏览器是无头浏览器
-
什么是无头浏览器(headlessbrowser)?无头浏览器是指可以在图形界面情况下运行的浏览器。我可以通过编程来控制无头浏览器自动执行各种任务,比如做测试,给网页截屏等。为什么叫“无头”浏览器?...
- 12个高效的Python爬虫框架,你用过几个?
-
实现爬虫技术的编程环境有很多种,Java、Python、C++等都可以用来爬虫。但很多人选择Python来写爬虫,为什么呢?因为Python确实很适合做爬虫,丰富的第三方库十分强大,简单几行代码便可实...
- 运维的报表之路,用 node.js 轻松发送 grafana 报表
-
在运维过程中,无论是监控还是报表,都会有一些通过邮件发送图表的需求,由于开源的zabbix,grafana和kibana等并不完全具有“想发送哪儿就发送哪儿”的图片生成功能,在grafana...
- C#基于浏览器内核的高级爬虫(c#爬取网页内容)
-
基于C#.NET+PhantomJS+Sellenium的高级网络爬虫程序。可执行Javascript代码、触发各类事件、操纵页面Dom结构、甚至可以移除不喜欢的CSS样式。很多网站都用Ajax动态加...
- 如何优化一个秒杀项目?(秒杀实现思路)
-
问题1:使用jmeter性能压测,定位瓶颈代码步骤流程:线程组--->Http请求--->查看结果树--->聚合报告tips:host的文件--->优先调用映射,减少DNS的时...
- 一周热门
-
-
Python实现人事自动打卡,再也不会被批评
-
Psutil + Flask + Pyecharts + Bootstrap 开发动态可视化系统监控
-
一个解决支持HTML/CSS/JS网页转PDF(高质量)的终极解决方案
-
【验证码逆向专栏】vaptcha 手势验证码逆向分析
-
再见Swagger UI 国人开源了一款超好用的 API 文档生成框架,真香
-
网页转成pdf文件的经验分享 网页转成pdf文件的经验分享怎么弄
-
C++ std::vector 简介
-
python使用fitz模块提取pdf中的图片
-
《人人译客》如何规划你的移动电商网站(2)
-
Jupyterhub安装教程 jupyter怎么安装包
-
- 最近发表
- 标签列表
-
- 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)
- table.render (33)
- uniapp textarea (33)
- python判断元素在不在列表里 (34)
- python 字典删除元素 (34)
- react-admin (33)
- vscode切换git分支 (35)
- vscode美化代码 (33)
- python bytes转16进制 (35)