14 KiB
加餐2 | SpriteJS:我是如何设计一个可视化图形渲染引擎的?
你好,我是月影。
今天,我们来聊一个相对轻松的话题,它不会有太多的代码,也不会有什么必须要掌握的理论知识。不过这个话题对你理解可视化,了解渲染引擎也是有帮助的。因为我今天要聊的话题是SpriteJS,这个我亲自设计和实现的图形渲染引擎的版本迭代和演进。
SpriteJS是从2017年下半年开始设计的,到今天已经快三年了,它的大版本也从1.0升级到了3.0。那么它为什么会被设计出来?它有什么特点?1.0、2.0、3.0版本之间有什么区别,未来会不会有4.0甚至5.0?别着急,听我一一道来。
SpriteJS v1.x (2017年~2018年)
我们把时间调回到2017年下半年,当时我还在360奇舞团。奇舞团是360技术中台的前端团队,主要负责Web开发,包括PC端和移动端的产品的前端开发,比较少涉及可视化的内容。不过,虽然团队以支持传统Web开发为主,但是也支持过一部分可视化项目,比如一些toB系统的后台图表展现。那个时候,我们团队正要开始尝试探索可视化的方向。
如果你读过专栏的预习篇,你应该知道,要实现可视化图表,我们用图表库或者数据驱动框架都能够实现,前者使用起来简单,而后者更加灵活。当时,奇舞团的小伙伴更多是使用数据驱动框架D3.js来实现可视化图表的。
对D3.js来说,D3-selection是其核心子模块之一,它可以用来操作DOM树,返回选中的DOM元素集合。这个操作非常有用,因为它让我们可以像使用jQuery那样,快速遍历DOM元素,并且它通过data映射将数据与DOM元素对应起来。这样,我们用很简单的代码就能实现想要的可视化效果了。
比如,我们通过 d3.select('body').selectAll('div').dataset(data).enter().append('div')
,把对应的div元素根据数据的数量添加到页面上的body元素下,然后,我们直接通过.style来操作对应添加的div元素,修改它的样式,就能轻松绘制出一个简单的柱状图效果了。
const dataset = [125, 121, 127, 193, 309];
const colors = ['#fe645b', '#feb050', '#c2af87', '#81b848', '#55abf8'];
const chart = d3.select('body')
.selectAll('div')
.data(dataset)
.enter()
.append('div')
.style('left', '450px')
.style('top', (d, i) => {
return `${200 + i * 45}px`;
})
.style('width', d => `${d}px`)
.style('height', '40px')
.style('background', (d, i) => colors[i]);
这是一个非常快速且方便的绘图方式,但它也有局限性。D3-selection只能操作具有DOM结构的图形系统,也就是HTML和SVG。而对于Canvas和WebGL,我们就没有办法像上面一样,直接遍历元素并且将数据和元素结构对应起来。
正因为D3-selection操作DOM使用起来特别方便,所以常见的D3例子都是用HTML或者SVG来写的,很少使用Canvas和WebGL,即便后两者的性能要大大优于HTML和SVG。因此,当时实现SpriteJS 1.0的初衷非常简单,那就是我希望让团队的同学既能使用熟悉的D3.js来支持可视化图表的展现,又可以使用Canvas来代替默认的SVG进行渲染,从而达到更好的性能。
所以,SpriteJS 1.0实现了整个DOM底层的API,我们可以像操作浏览器原生的DOM一样来操作SpriteJS元素,而我们最终渲染出的图形是调用底层Canvas的API绘制到画布上的。这样一来,SpriteJS和HTML或者SVG,就都可以用D3-selection来操作了,在使用上它们没有特别大的差别,但SpriteJS的最终渲染还是通过Canvas绘制的,性能相比其他两种有了较大的提升。
比如说,我用D3.js配合SpriteJS实现的柱状图代码,与使用HTML绘制的代码区别不大,但是由于是绘制在Canvas上,性能会提升很多。
const {Scene, Sprite} = spritejs;
const container = document.getElementById('container');
const scene = new Scene({
container,
width: 800,
height: 800,
});
const dataset = [125, 121, 127, 193, 309];
const colors = ['#fe645b', '#feb050', '#c2af87', '#81b848', '#55abf8'];
const fglayer = scene.layer('fglayer');
const chart = d3.select(fglayer)
.selectAll('sprite')
.data(dataset)
.enter()
.append('sprite')
.attr('x', 450)
.attr('y', (d, i) => {
return 200 + i * 45;
})
.attr('width', d => d)
.attr('height', 40)
.attr('bgcolor', (d, i) => colors[i]);
除了解决API的问题,以及让D3-selection可以使用之外,为了让使用方式尽可能接近于原生的DOM,我还让SpriteJS 1.0 实现了这4个特性,分别是标准的DOM元素盒模型、标准的DOM事件、Web Animation API (动画)以及缓存策略。
盒模型、DOM事件和 Web Animation API ,我想你作为前端工程师肯定都知道,所以我多说一下缓存策略。还记得在性能篇里我们说过,要提升Canvas的渲染性能,就要尽量减少绘图指令的数量和执行时间,比较有效的方式是,我们可以将绘制的图形用离屏Canvas缓存下来。这样,在下次绘制的时候,我们就可以将缓存未失效的元素从缓存中用drawImage的方式直接绘制出来,而不用重新执行绘制元素的绘图指令,也就大大提升了性能。
因此,在SpriteJS 1.0中,我实现了一套自动的缓存策略,它会根据代码运行判断是否对一个元素启用缓存,如果是,就尽可能地启用缓存,让渲染性能达到比较好的水平。
SpriteJS 1.0实现的这些特性,基本上满足了我们当时的需要,让我们团队可以用D3.js配合SpriteJS来实现各种可视化图表项目需求,而且使用上非常接近于操作原生的DOM,非常容易上手。
SpriteJS v2.x (2018年~2019年)
到了2018年底,我开始思考SpriteJS的下一个版本。当时我们解决了在PC和移动Web上绘制可视化图表的诉求,不过外部的使用者和我们自己,在一些使用场景中,逐渐开始有一些跨平台的需求,比如在服务端渲染,或者在小程序中渲染。
因此,我开始重构代码,将绘图系统分层设计,实现了渲染的适配层。在适配层中,所有的绘图能力都由Canvas底层API提供,与浏览器DOM和其他的API无关。这样,SpriteJS就能够运行在任何提供了Canvas运行时环境的系统中,而不一定是浏览器。
重构后的代码能够通过node-canvas运行在Node.js环境中,所以我们就能够使用服务端渲染来实现一些特殊的可视化项目。比如,我们曾经有一个项目要处理大量的历史数据,大概有几十万到上百万条记录,如果在前端分别绘制它们,性能一定会有问题。所以,我们将它们通过服务端绘制并缓存好之后,以图像的方式发送给前端,这样就大大提升了性能。此外,我们还通过在适配层上提供不同的封装,让SpriteJS 2.0支持了小程序环境,也能够运行在微信小程序中。
上图是SpriteJS 2.0的主体架构,它的底层由一些通用模块组成,Sprite-core是适配层,SpriteJS是支持浏览器和Node.js的运行时,Sprite-wxapp是小程序运行时,Sprite-extend-*是一些外部扩展。我们通过外部扩展实现了粒子系统和物理引擎,以及对主流响应式框架的支持,让SpriteJS 2.0可以直接支持vue和react。
除此以外,SpriteJS 2.0还支持了文字排版和布局系统。其中,文字排版支持了多行文本自动换行,实现了几乎所有CSS3支持的文字排版属性,布局系统则支持了完整的弹性布局(Flex layout)。这两个特性被很多用户喜爱。
可以说,我们对SpriteJS 2.0做了加法,让它在1.0的基础上增加了许多强大且有用的特性。到了2019年底,我又开始思考实现SpriteJS 3.0。这次我打算对特性做一些取舍,将许多特性从SpriteJS 3.0中去掉,甚至包括深受使用者喜爱的文字排版和布局系统。这又是为什么呢?
这是因为SpriteJS 2.0虽好,但是它也有一些明显的缺点:
- 只支持Canvas2D,尽管有缓存策略,性能仍然不足;
- 多平台适配采用不同的分支,维护起来比较麻烦;
- 支持了许多非核心功能,如文字排版、布局,使得JavaScript文件太大;
- 不支持3D绘图。
SpriteJS v3.x (2019年~2020年)
在SpriteJS 3.0中,我舍弃了非核心功能,将SpriteJS定位为纯粹的图形渲染引擎, 核心目标是追求极致的性能。
在适配层上,SpriteJS 3.0完全舍弃了2.0设计里面较重的sprite-core,采用了更轻量级的图形库mesh.js作为2D适配层,mesh.js以gl-renderer作为webgl渲染底层库,结合Canvas2D的polyfill做到了优雅降级。当运行环境支持WebGL2.0时,SpriteJS 3.0默认采用WebGL2.0渲染,否则降级为WebGL1.0,如果也不支持WebGL1.0,再最终降级为Canvas2D。
在3D适配层方面,SpriteJS 3.0采用了OGL库。这样一来,SpriteJS 3.0就完全支持WebGL渲染,能够绘制2D和3D图形了。
SpriteJS 3.0继承了SpriteJS 2.0的跨平台性,但是不再需要使用分支来适配多平台,而是采用了更轻量级的polyfill设计,同时支持服务端渲染、Web浏览器渲染和微信小程序渲染,理论上讲还可以移植到其他支持WebGL或Canvas2D的运行环境中去。
与SpriteJS 1.0和SpriteJS 2.0采用缓存机制优化性能不同,SpriteJS 3.0默认采用WebGL渲染,因此使用了批量渲染的优化策略,我们在性能篇中讲过这种策略,在绘制大量几何图形时,它能够显著提升WebGL渲染的性能。
由于发挥了GPU并行计算的能力,在大批量图形绘制的性能上,SpriteJS 3.0的性能大约是SpriteJS 2.0的100倍。此外,SpriteJS 3.0支持了多线程渲染,可避免UI阻塞,从而进一步提升性能。
总之,SpriteJS 3.0 随着性能的优化,已经成为一个纯粹的可视化渲染引擎了,但在我看来它仍然有些问题:
- 性能优化得不够极致,数据压缩和批量渲染没有做到最好;
- JS的矩阵运算还是不够快,计算性能有提升空间;
- 因为考虑到兼容性的问题,所以我采用了Canvas2D的降级,这让JavaScript包仍然有些大;
- 3D能力不够强,与ThreeJS等主流3D引擎仍有差距。
SpriteJS的未来版本(2020年~2021年)
今年下半年,我开始设计SpriteJS 4.0。这一次,我打算把它打造成一个更纯粹的图形系统,让它可以做到真正跨平台,完全不依赖于Web浏览器。
下面是SpriteJS 4.0的结构图,它的底层将采用OpenGL ES和Skia来渲染3D和2D图形,中间层使用JavaScript Core和JS Bindings技术,将底层Api通过JavaScript导出,然后在上层适配层实现 WebGL、WebGPU和Canvas2D的API,最上层实现SpriteJS的API。
根据这个设计,SpriteJS 4.0将对浏览器完全没有依赖,同时依然可以通过Web Assembly方式运行在浏览器上。这样SpriteJS 4.0会成为真正跨平台的图形系统,可以以非常小的包集成到其他系统和原生App中,并且达到原生应用的性能。
在这一版,我还会全面优化SpriteJS的内存管理、矩阵运算和多线程机制,力求渲染性能再上一个台阶,最终能够完全超越现在市面上的任何主流的图形系统。
要点总结
在SpriteJS 1.0中,我们追求的是和DOM一致的API,能够使用D3.js结合SpriteJS来绘制可视化图表到Canvas,从而提升性能。到了SpriteJS 2.0,我们追求跨平台能力和一些强大的功能扩展,比如文字排版和布局系统。而到了SpriteJS 3.0,我们决定回归到渲染引擎本质,追求极致的性能发挥GPU的能力,并支持3D渲染。再到今年的SpriteJS 4.0,我打算把它打造成更纯粹的图形系统,让它的渲染能力和性能最终能够超越目前市面上的主流图形系统。
总的来说,在SpriteJS 1.0到4.0的设计发展过程中,包含了我对整个图形系统架构的思考和取舍。我希望通过我今天的分享,能够帮助你理解图形系统和渲染引擎的设计,也期待在你设计其他系统和平台的时候,它们能给你启发。
课后思考
最后,请你试着回想你曾经接触过的可视化项目,如果用SpriteJS来实现它们会不会有更好的效果呢?欢迎把你的思考和答案写在留言区,我们一起讨论。
看了我给SpriteJS未来版本定下的目标,你有没有心动呢?SpriteJS是一个开源项目,如果你学完这门课,也想参与进SpriteJS的开发,那我非常欢迎你成为一名SpriteJS开发者,为我们提交PR、贡献代码。
好了,今天的内容就到这里,我们下节课见!