从 2017 年到 2020 年,我花了大约 4 年的时刻,从零到一,完结了一个可切换 WebGL 和 Canvas2D 烘托的,跨渠道支撑浏览器、SSR、小程序,依据 DOM 结构和支撑呼应式的,高功能支撑批量烘托、针对可视化场景优化、支撑 WebWorker 的图形体系——SpriteJS。

在这个“造轮子”过程中,我一步步将一个很简陋的烘托库,变成一个可以支撑可视化应用和游戏开发的,还算不错的一个图形库,其间有许多堆集,也有许多思考。因为毕竟是两年多前的研讨,有些细节或许记得不是特别清晰,其间有些特性或许已经有点过时,但我想,仍是有不少内容能给咱们带来参阅和启发。

1. 原始需求:和烘托无关

2017 年底的时分,我还在奇虎 360 担任奇舞团。奇舞团是一个中台前端团队,支撑许多 360 的业务需求,其间包含一些 toB 的需求,这些需求中有不少可视化图表和态势感知大屏。大概在 2015-2016 年,咱们的同学就开端用 D3 来完结可视化项目,因为 D3 具有很高的灵敏性。有些同学将 D3 简略归类为一种可视化烘托结构,实际上这种主意是过错的。D3 并不是可视化结构,而是一个数据驱动引擎。

严格来说,D3 关心的是数据的组织,它并不关心数据终究烘托的成果,可是,D3 的数据组织办法是依据树状结构的,因为它天然契合树状结构的烘托办法。正因为如此,所以一般来说,D3 的官方比方都是用 DOM 或 SVG 烘托,这是因为依据 DOM 树的烘托和 D3 的树状数据组织办法是绝配。

  • 运用 DOM 烘托的 D3 柱状图:

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7160491…

  • 运用 SpriteJS 烘托:

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7160553…

1.1 与 DOM 的一致性

为了达到上面的效果,SpriteJS 参阅浏览器 DOM API,进行了适配:

  • github.com/spritejs/sp…
  • github.com/spritejs/sp…
  • github.com/spritejs/sp…
  • github.com/spritejs/sp…
  • github.com/spritejs/sp…

1.2 SpriteJS & DOM & D3

理论上,操作 SpriteJS 元素和操作 DOM 元素彻底一样,二者差异极小。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7160568…

这种一致性使得 SpriteJS 彻底可以和 D3 合作运用,灵敏解决非常复杂的可视化问题:spritejs.com/#/zh-cn/gui…

2. 规划一个图形体系的“骨架”

2.1 坐标系的挑选

在图形体系的规划中,首要要确认默许坐标系。理论上讲,任何一种直角坐标系,乃至非直角坐标系(比方极坐标)都可以作为默许坐标系,在欧式几何中,这些坐标系都可以自由转化。不过,考虑与 DOM 的一致性,选用浏览器默许的坐标系是一个极好的挑选。

关于 WebGL 烘托来说,咱们需求将极点坐标转化成 WebGL 坐标,在这儿,咱们选用依据 canvas 的坐标动态设置 projectionMatrix 即可:github.com/mesh-js/mes…

updateResolution(){
const{width,height}=this.canvas;
constm1=[//translation
1,0,0,
0,1,0,
-width/2,-height/2,1,
];
constm2=[//scale
2/width,0,0,
0,-2/height,0,
0,0,1,
];
constm3=mat3(m2)*mat3(m1);
this.projectionMatrix=m3;
if(this[_glRenderer]){
this[_glRenderer].gl.viewport(0,0,width,height);
}
}
attribute vec3 a_vertexPosition;
attribute vec3 a_vertexTextureCoord;
varying vec3 vTextureCoord;
uniform mat3 viewMatrix;
uniform mat3 projectionMatrix;
void main() {
  gl_PointSize = 1.0;
  vec3 pos = projectionMatrix * viewMatrix * vec3(a_vertexPosition.xy, 1.0);
  gl_Position = vec4(pos.xy, 1.0, 1.0);
  vTextureCoord = a_vertexTextureCoord;
}

2.2 图层、树形结构与元素类型

SpriteJS 用 Scene 表明场景,一个 Layer 表明一个图层,在这儿,我的规划是一个 Layer 对应一个画布,即默许每个 Layer 都是独立的 Canvas 元素。这么做有长处也有缺陷,是一种规划上的取舍。

长处是,每个 Layer 彼此独立,Layer 间不必考虑制作次序,可以充分利用 WebWorker 这样的多线程来并行制作,而且逻辑上比较简略,假如需求在多层呼应事情,只需求留意事情处理的次序。缺陷是假如分多层制作,有或许产生较多 Canvas 目标实例,比较耗内存。

  • 多线程制作

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7089291…

前面说过,SpriteJS 选用类似树状结构来办理元素,Scene、Layer 和 Group 都是容器,而其他类型的图形元素挂载在容器上。

SpriteJS:图形库造轮子的那些事儿

SpriteJS 的元素类型比较多,一共有超越十五种图形元素,如下图所示。

SpriteJS:图形库造轮子的那些事儿

这些元素可以分为两类,一类是 Block 元素,包含 Sprite、Label 和 Group,一类是 Path 元素,包含各种图形。这两类元素中,Block 比较类似于 DOM 元素,占据矩形区域,有盒模型,有 border、padding、margin,可以核算大小;Path 比较类似于 SVG 元素,经过 Path2D 构成矢量形状,有 stroke 和 fill 两类烘托,但不核算大小(不管 Path 仍是 Block 都能核算 boundingClientRect)。

Group 比较特别,SpriteJS v3 里,它默许不核算大小,但继承它的 Layer 和 Scene 会核算大小。在 v2 中,Group 核算大小,而且可以做区域取舍和设置 clipPath。v3 里,Group 主要的作用是给分组元素设置统一的 transform。之所以这样规划,牵扯到 WebGL 的烘托模型。在后续会详细解释。

考虑到扩展性,用户可以经过 spritejs.registerNode 注册自定义节点元素。github.com/spritejs/sp…

registerNode 的作用是注册一个仅有的 nodeName 到 spritejs 的文档树上,这样节点挂载之后,经过 getElementById、querySelector 等等就可以找到这个节点。

2.3 特点更新和重绘机制

SpriteJS 与一般的图形库不同,通常状况下,一般的图形库会运用一个动画定时器来以固定帧率刷新画布。但 SpriteJS 选用的是特点改动时的异步更新机制。

  • github.com/spritejs/sp…
  • github.com/spritejs/sp…

详细原理如下图所示:

SpriteJS:图形库造轮子的那些事儿

这儿有些需求留意的细节:

  1. 不是所有的特点改动都会触发 render,比方 className、ID 等改动不会触发。
  2. 有些特点改动不仅触发 render,还需求触发其他操作,比方 anchor、border 等特点的改动,需求从头核算图形元素的概括(后面会讲);zIndex 的改动,导致对 group 的 children 的 renderOrder 进行重排。

这样规划的好处清楚明了,可以尽量减少不必要的重绘和其他核算,从而提高整体功能。

2.4 外部 Ticker

尽管 SpriteJS 有自己的更新机制,可是一些外部库,比方 ThreeJS 或者 ClayGL,有自己的更新逻辑,所以 SpriteJS 添加了手动操控的规划,以便利与外部库合作。spritejs.com/#/zh-cn/gui…

2.5 跨渠道

SpriteJS 在完结的时分,尽量不运用浏览器原生供给的才能,除非是规范的 Canvas 和 WebGL API。针对浏览器、NodeJS、微信小程序、微信小游戏等不同的环境,经过 polyfill 进行适配。github.com/spritejs/sp…

为了在 NodeJS 中集成 WebGL 和 Canvas 环境,做了下面这个库:github.com/akira-cn/no…

3. 盒模型、事情、动画等

3.1 盒模型规划

对 Block 类型的元素,SprteJS 选用规范的 DOM 盒模型,可以设置 border、padding 各特点,并可以经过 boxSizing 特点切换盒模型办法。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7160923…

3.2 事情机制

  • 事情模型、坐标转化
  • github.com/spritejs/sp…
  • github.com/spritejs/sp…

视口宽高:[viewportWidth, viewportHeight]

画布宽高:[resolutionWidth, resolutionHeight]

偏移量:[offsetLeft, offsetTop]

为什么会产生偏移量,详细见屏幕适配。

  • 事情派发和射中

    github.com/spritejs/sp…

    github.com/spritejs/sp…

    github.com/mesh-js/mes…

选用对每个三角网格进行射中检测(此处有优化空间,可以先排序用二分查找快速确认范围):

SpriteJS:图形库造轮子的那些事儿

functioninTriangle(p1,p2,p3,point){
consta=p2.copy().sub(p1);
constb=p3.copy().sub(p2);
constc=p1.copy().sub(p3);
constu1=point.copy().sub(p1);
constu2=point.copy().sub(p2);
constu3=point.copy().sub(p3);
consts1=Math.sign(a.cross(u1));
letp=a.dot(u1)/a.length**2;
if(s1===0&&p>=0&&p<=1)returntrue;
consts2=Math.sign(b.cross(u2));
p=b.dot(u2)/b.length**2;
if(s2===0&&p>=0&&p<=1)returntrue;
consts3=Math.sign(c.cross(u3));
p=c.dot(u3)/c.length**2;
if(s3===0&&p>=0&&p<=1)returntrue;
returns1===s2&&s2===s3;
}

3.3 动画的规划

  • Sprite-Timeline

    github.com/spritejs/sp…

为了完结可以在时刻轴按照恣意速度播映动画,包含正向播映和回放,在恣意时刻点可以跳动,实时切换播映状况和时刻轴状况,规划了 sprite-timeline 库。

这个库的规划是:

  1. 创立一个 Timeline 目标,它依据当前时刻线和 playbackRate 来核算时刻,playbackRate 可以是恣意数,所以时刻可以中止,也可以回溯。playbackRate 的设置和改动会影响 Timeline 目标的 currentTime。
  2. 除了 currentTime 特点,Timeline 目标还有一个 entropy(熵)特点,它和 currentTime 的不同是,假如 playbackRate 为负数,currentTime 会回溯,但 entropy 始终添加。
  3. Timeline 目标可以 fork,fork 出的新目标以被 fork 的 Timeline 目标的 currentTime 为时刻线。这意味着 Timeline 目标可以嵌套,在 SpriteJS 中,所有元素会默许 fork 它的 parent 的 timeline 目标,所以当咱们把 layer 的 timeline 的 playbackRate 设置为 0 的时分,这个 layer 中所有的动画就都会暂停。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7160950…

  • Sprite-animator

    依据 timeline 封装,参阅 Web Animations API – Web APIs | MDN(developer.mozilla.org/en-US/docs/…

  • Animation & Transition

    spritejs.com/#/zh-cn/eff…

  • Transition-reverse

    检查代码:code./pen/7089261…

  • Path Transition

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7160959…

  • Play Animations

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088265…

  • Async frame animations

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088238…

4. 从 2D 到 WebGL

在 Sprite 1.0 和 2.0 的时分,主要是运用 Canvas2D 烘托,直到 3.0,我重写了底层引擎,开端默许选用 WebGL 烘托。

4.1 概括和网格

为了便于 WebGL 处理几何图形,尤其是 Path 的解析,我完结了一个底层烘托引擎 GitHub – mesh-js/mesh.js: A graphics system born for visualization(github.com/mesh-js/mes… ),将 2D 几何图形分解成概括和网格目标,这有点像是 ThreeJS 中的 Geometry 和 Material,只不过因为咱们要处理的实际上是 2D 图形,所以模型更加简略。

在 mesh.js 中,要制作一个几何图形,咱们先构建该元素的概括(Figure/Contours),然后再依据概括创立网格目标。经过这样两个步骤之后,咱们就可以将几何图形制作出来,这个过程其实比较像 Canvas2D,仅仅比 Canvas2D 稍复杂一点点。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7160967…

4.2 三角剖分

众所周知,WebGL 的基本图元只有点、线、三角形等,要制作多边形,咱们需求将图形进行三角剖分。对恣意多边形进行三角剖分,有许多老练算法,我挑选的是 GLU Tessellator。

  • github.com/mesh-js/mes…
  • github.com/mesh-js/mes…

我经过一系列东西库 parse-svg-path、normalize-svg-path、svg-path-contours(github.com/mesh-js/mes… )将 SVGPath 转化成多边形的极点列表,这儿就不重复造轮子了,有些东西库有点小 bug,我给顺手修了一下。

取得极点之后,对极点进行三角剖分,就可以得到三角网格的拓扑结构,经过这个拓扑结构创立 mesh2d 目标。

4.3 Stroke

假如不常用 WebGL 烘托,很难想象,对 Canvas2D 来说非常简略的制作带宽度折线这类需求,会难住 WebGL 开发者。

SpriteJS:图形库造轮子的那些事儿

其实这个问题已经有比较经典的解决方案,便是用揉捏(extrude polyline)曲线技能来完结。有两种办法,一种是用 JS 算极点,另一种是在 shader 中进行处理。为了灵敏完结 Canvas2D 中的“线帽(lineCap)”效果,SpriteJS 选用 JS 核算的办法来处理。

SpriteJS:图形库造轮子的那些事儿

如上图所示,黑色折线是原始的 1 个像素宽度的折线,蓝色虚线组成的是咱们终究要生成的带宽度曲线,红色虚线是极点移动的方向。因为折线两个端点的揉捏只和一条线段的方向有关,而转角处极点的揉捏和相邻两条线段的方向都有关,所以极点移动的方向,咱们要分两种状况评论。

首要,是折线的端点。假设线段的向量为(x, y),因为它移动方向和线段方向笔直,所以咱们只要沿法线方向移动它就可以了。依据笔直向量的点积为 0,咱们很简单得出极点的两个移动方向为(-y, x)和(y, -x)。如下图所示:

SpriteJS:图形库造轮子的那些事儿

端点揉捏方向确认了,接下来要确认转角的揉捏方向了,咱们仍是看示意图。

SpriteJS:图形库造轮子的那些事儿

如上图,咱们假设有折线 abc,b 是转角。咱们延伸 ab,就能得到一个单位向量 v1,反向延伸 bc,可以得到另一个单位向量 v2,那么揉捏方向便是向量 v1+v2 的方向,以及相反的 -(v1+v2) 的方向。

现在咱们得到了揉捏方向,接下来就需求确认揉捏向量的长度。

首要是折线端点的揉捏长度,它等于 lineWidth 的一半。而转角的揉捏长度就比较复杂了,咱们需求再核算一下。

SpriteJS:图形库造轮子的那些事儿

绿色这条辅助线应该等于 lineWidth 的一半,而它又恰好是 v1+v2 在绿色这条向量方向的投影,所以,咱们可以先用向量点积求出红色虚线和绿色虚线夹角的余弦值,然后用 lineWidth 的一半除以这个值,得到的便是揉捏向量的长度了。

详细用 JavaScript 完结的代码如下所示:github.com/mesh-js/mes…

functionextrudePolyline(gl,points,{thickness=10}={}){
consthalfThick=0.5*thickness;
constinnerSide=[];
constouterSide=[];
//构建揉捏极点
for(leti=1;i<points.length-1;i++){
constv1=(newVec2()).sub(points[i],points[i-1]).normalize();
constv2=(newVec2()).sub(points[i],points[i+1]).normalize();
constv=(newVec2()).add(v1,v2).normalize();//得到揉捏方向
constnorm=newVec2(-v1.y,v1.x);//法线方向
constcos=norm.dot(v);
constlen=halfThick/cos;
if(i===1){//起始点
constv0=newVec2(...norm).scale(halfThick);
outerSide.push((newVec2()).add(points[0],v0));
innerSide.push((newVec2()).sub(points[0],v0));
}
v.scale(len);
outerSide.push((newVec2()).add(points[i],v));
innerSide.push((newVec2()).sub(points[i],v));
if(i===points.length-2){//结束点
constnorm2=newVec2(v2.y,-v2.x);
constv0=newVec2(...norm2).scale(halfThick);
outerSide.push((newVec2()).add(points[points.length-1],v0));
innerSide.push((newVec2()).sub(points[points.length-1],v0));
}
}
...
}

4.4 批量制作

因为咱们制作 2D 图形,通常这些图形可视为同一材质,所以咱们可以将这些图形网格数据全部压缩到一个大的类型数组中进行批量制作。

github.com/mesh-js/mes…

SpriteJS:图形库造轮子的那些事儿

SpriteJS:图形库造轮子的那些事儿

4.5 Shader & Pass

SpriteJS 可以运用自定义 shader 创立 Program,将 Program 赋给绘图元素进行制作。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088623…

咱们可以在烘托管线中应用多个 shader 组成管道进行烘托,有一种特定的烘托管道叫做后期处理通道,SpriteJS 支撑定义后期处理通道。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088626…

5. 关于功能优化的那些事儿

5.1 功能的直观感触

SpriteJS 针对可视化场景进行了功能优化。可视化场景中有很多重复或类似形状的几何图形,因而用兼并极点批量烘托的办法会很有效。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088268…

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088274…

5.2 auto Blending 和概括更新

WebGL 在色彩混合的时分比较耗费功能,因而 mesh-js 对元素做了判别,假如当前制作的元素都没有 alpha 通道(透明度),那么不会敞开色彩混合,否则再敞开色彩混合。

在 SpriteJS 中,元素的大部分样式改动,比方 transform、position、bgcolor 等等,不涉及概括的改动,这些状况下,咱们不必从头核算概括,所以咱们将元素概括核算好之后缓存起来,大部分状况下咱们不需求重复核算。只有一些特别特点,比方 Path 的 d、lineWidth、lineCap、Block 的 border 等改动,才需求从头核算概括。

5.3 Seal & Cloud

spritejs.com/#/zh-cn/gui…

Seal 是一种特别的办法,当咱们运用一个 group 来组合一组图形时,假如仅仅需求运用固定的图形拓扑结构,咱们可以运用 group 的 seal 办法将子元素的几何图形兼并成为 group 的几何图形。这样 group 的几何图形将被兼并的几何图形代替,成为一个单一的元素被烘托,而且不再可以改动几何图形(可是依然可以改动位置、transform、色彩等等特点)。

seal 生效的时分,原子元素的特点将失效,由 group 的特点代替。

当咱们用 group 构建组合图形的时分,这种特别办法可以大大提升烘托功能。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088273…

关于制作彻底重复的几何图形,咱们还可以利用 WebGL 的来进行烘托。

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088273…

SpriteJS:图形库造轮子的那些事儿

检查代码:code./pen/7088274…

5.4 关于 Shader 的功能开支

有一条需求分外留意:尽量运用条件编译代替条件分支

6. 一些细节,屏幕适配等

  • 黏连模式:spritejs.com/#/zh-cn/gui…
  • 资源加载:spritejs.com/#/zh-cn/gui…

字节内部课又双叒叕上新,充会员还送会员?!

各位技能发烧友们,「字节内部课」上新活动又来了~这个冬季,与字节工程师们同行,开通会员免费学,活动期间,经过「字节内部课」相关页面完结会员充值/续费,将有机会取得礼品一份!年度锦鲤充会员还送会员哦!

12 月 28 号课程上新日,活动详情「点击这儿」~