你好,我是月影。

学了这么多图形学的基础知识和WebGL的视觉呈现技术,你一定已经迫不及待地想要开始实战了吧?今天,我带你完成一个小型的可视化项目,带你体会一下可视化开发的全过程。也正好借此机会,复习一下我们前面学过的全部知识。

这节课,我们要带你完成一个GitHub贡献图表的可视化作品。GitHub贡献图表是一个统计表,它统计了我们在GitHub中提交开源项目代码的次数。我们可以在GitHub账号信息的个人详情页中找到它。

下图中的红框部分就是我的贡献图表。你会看到,GitHub默认的贡献图表可视化展现是二维的,那我们要做的,就是把它改造为简单的动态3D柱状图表。

第一步:准备要展现的数据

想要实现可视化图表,第一步就是准备数据。GitHub上有第三方API可以获得指定用户的GitHub贡献数据,具体可以看这个项目

通过API,我们可以事先保存好一份JSON格式的数据,具体的格式和内容大致如下:

// github_contributions_akira-cn.json

{
  "contributions": [
    {
      "date": "2020-06-12",
      "count": 1,
      "color":"#c6e48b",
    },
    ...
  ],
}

从这份JSON文件中,我们可以取出每一天的提交次数count,以及一个颜色数据color。每天提交的次数越多,颜色就越深。有了这份数据内容,我们就可以着手实现具体的展现了。不过,因为数据很多,所以这次我们只想展现最近一年的数据。我们可以写一个函数,根据传入的时间对数据进行过滤。

这个函数的代码如下:

let cache = null;
async function getData(toDate = new Date()) {
  if(!cache) {
    const data = await (await fetch('../assets/github_contributions_akira-cn.json')).json();
    cache = data.contributions.map((o) => {
      o.date = new Date(o.date.replace(/-/g, '/'));
      return o;
    });
  }
  // 要拿到 toData 日期之前大约一年的数据(52周)
  let start = 0,
    end = cache.length;
  // 用二分法查找
  while(start < end - 1) {
    const mid = Math.floor(0.5 * (start + end));
    const {date} = cache[mid];
    if(date <= toDate) end = mid;
    else start = mid;
  }
  // 获得对应的一年左右的数据
  let day;
  if(end >= cache.length) {
    day = toDate.getDay();
  } else {
    const lastItem = cache[end];
    day = lastItem.date.getDay();
  }
  // 根据当前星期几,再往前拿52周的数据
  const len = 7 * 52 + day + 1;
  const ret = cache.slice(end, end + len);
  if(ret.length < len) {
    // 日期超过了数据范围,补齐数据
    const pad = new Array(len - ret.length).fill({count: 0, color: '#ebedf0'});
    ret.push(...pad);
  }
  return ret;
}

这个函数的逻辑是,先从JSON文件中读取数据并缓存起来,然后传入对应的日期对象,获取该日期之前大约一年的数据(准确来说是该日期的前52周数据,再加上该日期当前周直到该日期为止的数据,公式为 7*52 + day + 1)。

这样,我们就准备好了要用来展现的数据。

第二步:用SpriteJS渲染数据、完成绘图

有了数据之后,接下来我们就要把数据渲染出来,完成绘图。这里,我们要用到一个新的JavaScript库SpriteJS来绘制。

既然如此,我们先来熟悉一下SpriteJS库。

SpriteJS是基于WebGL的图形库,也是我设计和维护的开源可视化图形渲染引擎项目。它是一个支持树状元素结构的渲染库。也就是说,它和我们前端操作DOM类似,通过将元素一一添加到渲染树上,就可以完成最终的渲染。所以在后续的课程中,我们也会更多地用到它。

我们要用到的是SpriteJS的3D部分,它是基于我们熟悉的OGL库实现的。那我们为什么不直接用OGL库呢?这是因为SpriteJS在OGL的基础上,对几何体元素进行了类似DOM元素的封装。这样我们创建几何体元素就可以像操作DOM一样方便了,直接用d3库的selection子模块来操作就可以了。

1. 创建Scene对象

像DOM有documentElement作为根元素一样,SpriteJS也有根元素。SpriteJS的根元素是一个Scene对象,对应一个DOM元素作为容器。更形象点来说,我们可以把Scene理解为一个“场景”。那SpriteJS中渲染图形,都要在这个“场景”中进行。

接下来,我们就创建一个Scene对象,代码如下:

const container = document.getElementById('stage');

const scene = new Scene({
  container,
  displayRatio: 2,
});

创建Scene对象,我们需要两个参数。一个参数是container,它是一个HTML元素,在这里是一个id为stage的元素,这个元素会作为SpriteJS的容器元素,之后SpriteJS会在这个元素上创建Canvas子元素。

第二个参数是displayRatio,这个参数是用来设置显示分辨率的。你应该还记得,在讲Canvas绘图的时候,我们提到过,为了让绘制出来的图形能够适配不同的显示设备,我们要把Canvas的像素宽高和CSS样式宽高设置成不同的值。所以这里,我们把displayRatio设为2,就可以让像素宽高是CSS样式宽高的2倍,对于一些像素密度为2的设备(如iPhone的屏幕),这么设置才不会让画布上绘制的图片、文字变得模糊。

2. 创建Layer对象

有了scene对象,我们再创建一个或多个Layer对象,也可以理解为是一个或者多个“图层”。在SpriteJS中,一个Layer对象就对应于一个Canvas画布。

const layer = scene.layer3d('fglayer', {
  camera: {
    fov: 35,
  },
});
layer.camera.attributes.pos = [2, 6, 9];
layer.camera.lookAt([0, 0, 0]);

如上面代码所示,我们通过调用scene.layer3d方法,就可以在scene对象上创建了一个3D(WebGL)上下文的Canvas画布。而且这里,我们把相机的视角设置为35度,坐标位置为(2, 6, 9),相机朝向坐标原点。

3. 将数据转换成柱状元素

接着,我们就要把数据转换成画布上的长方体元素。我们可以借助d3-selection,d3是一个数据驱动文档的模型,d3-selection能够通过数据操作文档树,添加元素节点。当然,在使用d3-selection添加元素前,我们要先创建用来3D展示的WebGL程序。

因为SpriteJS提供了一些预置的着色器,比如shaders.GEOMETRY着色器,就是默认支持phong反射模型的一组着色器,我们直接调用它就可以了。

const program = layer.createProgram({
  vertex: shaders.GEOMETRY.vertex,
  fragment: shaders.GEOMETRY.fragment,
});

创建好WebGL程序之后,我们就可以获取数据,用数据来操作文档树了。

const dataset = await getData();
const max = d3.max(dataset, (a) => {
  return a.count;
});

/* globals d3 */
const selection = d3.select(layer);
const chart = selection.selectAll('cube')
  .data(dataset)
  .enter()
  .append(() => {
    return new Cube(program);
  })
  .attr('width', 0.14)
  .attr('depth', 0.14)
  .attr('height', 1)
  .attr('scaleY', (d) => {
    return d.count / max;
  })
  .attr('pos', (d, i) => {
    const x0 = -3.8 + 0.0717 + 0.0015;
    const z0 = -0.5 + 0.05 + 0.0015;
    const x = x0 + 0.143 * Math.floor(i / 7);
    const z = z0 + 0.143 * (i % 7);
    return [x, 0.5 * d.count /max, z];
  })
  .attr('colors', (d, i) => {
    return d.color;
  });

如上面代码所示,我们先通过d3.select(layer)对象获得一个selection对象,再通过getData()获得数据,接着通过selection.selectAll(‘cube’).data(dataset).enter().append(…)遍历数据,创建元素节点。

这里,我们创建了Cube元素,就是长方体在SpriteJS中对应的对象,然后让dataset的每一条记录对应一个Cube元素,接着我们还要设置每个Cube元素的样式,让数据进入cube以后,能体现出不同的形状。

具体来说,我们要设置长方体Cube的长(width)、宽(depth)、高(height)属性,以及y轴的缩放(scaleY),还有Cube的位置(pos)坐标和长方体的颜色(colors)。其中与数据有关的参数是scaleY、pos和colors,我就来详细说说它们。

对于scaleY,我们把它设置为d.count与max的比值。这里的max是指一年的提交记录中,提交代码最多那天的数值。这样,我们就可以保证scaleY的值在0~1之间,既不会太小、也不会太大。这种用相对数值来做可视化展现的做法,是可视化处理数据的一种常用基础技巧,在数据篇我们还会深入去讲。

而pos是根据数据的索引设置x和z来决定的。由于Cube的坐标基于中心点对齐的,现在我们想让它们变成底部对齐,所以需要把y设置为d.count/max的一半。

最后,我们再根据数据中的color值设置Cube的颜色。这样,我们通过数据将元素添加之后,画布上渲染出来的结果就是一个3D柱状图了,效果如下:

第三步:补充细节,实现更好的视觉效果

现在这个3D柱状图,还很粗糙。我们可以在此基础上,增加一些视觉上的细节效果。比如说,我们可以给这个柱状图添加光照。比如,我们可以修改环境光,把颜色设置成(0.5, 0.5, 0.5, 1),再添加一道白色的平行光,方向是(-3, -3, -1)。这样的话,柱状图就会有光照效果了。具体的代码和效果图如下:

const layer = scene.layer3d('fglayer', {
  ambientColor: [0.5, 0.5, 0.5, 1],
  camera: {
    fov: 35,
  },
});
layer.camera.attributes.pos = [2, 6, 9];
layer.camera.lookAt([0, 0, 0]);

const light = new Light({
  direction: [-3, -3, -1],
  color: [1, 1, 1, 1],
});

layer.addLight(light);

除此之外,我们还可以给柱状图增加一个底座,代码和效果图如下:

const fragment = `
  precision highp float;
  precision highp int;
  varying vec4 vColor;
  varying vec2 vUv;
  void main() {
    float x = fract(vUv.x * 53.0);
    float y = fract(vUv.y * 7.0);
    x = smoothstep(0.0, 0.1, x) - smoothstep(0.9, 1.0, x);
    y = smoothstep(0.0, 0.1, y) - smoothstep(0.9, 1.0, y);
    gl_FragColor = vColor * (x + y);
  }    
`;

const axisProgram = layer.createProgram({
  vertex: shaders.TEXTURE.vertex,
  fragment,
});

const ground = new Cube(axisProgram, {
  width: 7.6,
  height: 0.1,
  y: -0.049, // not 0.05 to avoid z-fighting
  depth: 1,
  colors: 'rgba(0, 0, 0, 0.1)',
});

layer.append(ground);

上面的代码不复杂,我想重点解释其中两处。首先是片元着色器代码,我们使用了根据纹理坐标来实现重复图案的技术。这个方法和我们第11节课说的思路完全一样,如果你对这个方法感到陌生了,可以回到前面复习一下。

其次,我们将底座的高度设置为0.1,y的值本来应该是-0.1的一半,也就是-0.05,但是我们设置为了-0.049。少了0.001是为了让上层的柱状图稍微“嵌入”到底座里,从而避免因为底座上部和柱状图底部的z坐标一样,导致渲染的时候由于次序问题出现闪烁,这个问题在图形学术语里面有一个名字叫做z-fighting。

z-fighting是3D绘图中的一个常见问题,所以我再多解释一下。在WebGL中绘制3D物体,一般我们开启了深度检测之后,引擎会自动计算3D物体的深度,让离观察者很近的物体面,把离观察者比较远和背对着观察者的物体面遮挡住。那具体是怎么遮挡的呢?其实是根据物体在相机空间中的z坐标来判断的。

但有一种特殊情况,就是两个面的z坐标相同,又有重叠的部分。这时候,引擎就可能一会儿先渲染A面,过一会儿又先去渲染B面,这样渲染出来的内容就出现了“闪烁”现象,这就是z-fighting。

z-fighting有很多解决方法,比如可以人为指定一下几何体渲染的次序,或者,就是让它们的坐标不要完全相同,在上面的例子里,我们就采用了让坐标不完全相同的处理办法。

最后,为了让实现出来的图形更有趣,我们再增加一个过渡动画,让柱状图的高度从不显示,到慢慢显示出来。

要实现这个效果,我们需要稍微修改一下d3.selection的代码。

  const chart = selection.selectAll('cube')
    .data(dataset)
    .enter()
    .append(() => {
      return new Cube(program);
    })
    .attr('width', 0.14)
    .attr('depth', 0.14)
    .attr('height', 1)
    .attr('scaleY', 0.001)
    .attr('pos', (d, i) => {
      const x0 = -3.8 + 0.0717 + 0.0015;
      const z0 = -0.5 + 0.05 + 0.0015;
      const x = x0 + 0.143 * Math.floor(i / 7);
      const z = z0 + 0.143 * (i % 7);
      return [x, 0, z];
    })
    .attr('colors', (d, i) => {
      return d.color;
    });

如上面代码所示,我们先把scaleY直接设为0.001,然后我们用d3.scaleLinear来创建一个线性的缩放过程,最后,我们通过chart.trainsition来实现这个线性动画。

const linear = d3.scaleLinear()
  .domain([0, max])
  .range([0, 1.0]);

chart.transition()
  .duration(2000)
  .attr('scaleY', (d, i) => {
    return linear(d.count);
  })
  .attr('y', (d, i) => {
    return 0.5 * linear(d.count);
  });

到这里呢,我们就实现了我们想要实现的所有效果了。

要点总结

这节课,我们一起实现了3D动态的GitHub贡献图表,整个实现过程可以总结为两步。

第一步是处理数据,我们可以通过API获取JSON数据,然后得到我们想要的数据格式。第二步是渲染数据,今天我们是使用SpriteJS来渲染的,它的API类似于DOM,对d3非常友好。所以我们可以直接使用d3-selection,以数据驱动文档的方式就可以构建几何体元素。

并且,为了更好地展现数据之间的变换关系,我们根据数据创建了Cube元素,并将它们渲染了出来。而且,我们还给实现的柱状元素设置了光照、实现了过渡动画,算是实现了一个比较完整的可视化效果。

此外,我们还要注意,在实现过渡动画的过程中,很容易出现z-fighting问题,也就是我们实现的元素由于次序问题,在渲染的时候出现闪烁。这个问题在可视化中非常常见,不过,我们通过设置渲染次序或者避免坐标相同就可以避免。

到这里,我们视觉进阶篇的内容就全部讲完了。这一篇,我从实现简单的动画,讲到了3D物体的绘制、旋转、移动,以及给它们添加光照效果、法线贴图,让它们能更贴近真实的物体。

说实话,这一篇的内容单看真的不简单。但你认真看了会发现,所有的知识都是环环相扣的,只要有了前几篇的基础,我们再来学肯定可以学会。为了帮助你梳理这一篇的内容,我总结了一张知识脑图放在了下面,你可以看看。

小试牛刀

我们今天讲的这个例子,你学会了吗?你可以用自己的GitHub贡献数据,来实现同样的图表,也可以稍微修改一下它的样式,比如采用不同的颜色、不同的光照效果等等。

另外,课程中的例子是默认获取最近一年到当天的数据,你也可以扩展一下功能,让这个图表可以设置日期范围,根据日期范围来呈现数据。

如果你的GitHub贡献数据不是很多,也可以去找相似平台上的数据,来实现类似的图表。

今天的实战项目有没有让你体会到可视化的魅力呢?那就快把它分享出去吧!我们下节课再见!


源码

实现3D可视化图表详细代码

推荐阅读

[1] SpriteJS官网
[2] d3-api

评论