gitbook/跟月影学可视化/docs/277226.md
2022-09-03 22:05:03 +08:00

356 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 27 | 案例如何实现简单的3D可视化图表
你好,我是月影。
学了这么多图形学的基础知识和WebGL的视觉呈现技术你一定已经迫不及待地想要开始实战了吧今天我带你完成一个小型的可视化项目带你体会一下可视化开发的全过程。也正好借此机会复习一下我们前面学过的全部知识。
这节课,我们要带你完成一个**GitHub贡献图表的可视化作品**。GitHub贡献图表是一个统计表它统计了我们在GitHub中提交开源项目代码的次数。我们可以在GitHub账号信息的个人详情页中找到它。
下图中的红框部分就是我的贡献图表。你会看到GitHub默认的贡献图表可视化展现是二维的那我们要做的就是把它改造为简单的动态3D柱状图表。
![](https://static001.geekbang.org/resource/image/4a/0b/4a44441b2431ce98d6139b89ae16f70b.jpg "GitHub默认的贡献图表可视化展现示意图")
## 第一步:准备要展现的数据
想要实现可视化图表第一步就是准备数据。GitHub上有第三方API可以获得指定用户的GitHub贡献数据具体可以看[这个项目](https://github.com/sallar/github-contributions-api)。
通过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](https://spritejs.org/#/)是基于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对象上创建了一个3DWebGL上下文的Canvas画布。而且这里我们把相机的视角设置为35度坐标位置为2, 6, 9相机朝向坐标原点。
### 3\. 将数据转换成柱状元素
接着,我们就要把数据转换成画布上的长方体元素。我们可以借助[d3-selection](https://github.com/d3/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柱状图了效果如下
![](https://static001.geekbang.org/resource/image/0c/a6/0c7a265e05d79336fc5a045dd6b3c0a6.gif)
## 第三步:补充细节,实现更好的视觉效果
现在这个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);
```
![](https://static001.geekbang.org/resource/image/0e/fb/0e9764123667yy18329ef01a4a6771fb.gif)
除此之外,我们还可以给柱状图增加一个底座,代码和效果图如下:
```
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);
```
![](https://static001.geekbang.org/resource/image/b1/ce/b1c0dec29b6e6b16c4d86e786f7d12ce.gif)
上面的代码不复杂,我想重点解释其中两处。首先是片元着色器代码,我们使用了根据纹理坐标来实现重复图案的技术。这个方法和我们[第11节课](https://time.geekbang.org/column/article/262330)说的思路完全一样,如果你对这个方法感到陌生了,可以回到前面复习一下。
其次我们将底座的高度设置为0.1y的值本来应该是-0.1的一半,也就是-0.05,但是我们设置为了-0.049。少了0.001是为了让上层的柱状图稍微“嵌入”到底座里从而避免因为底座上部和柱状图底部的z坐标一样导致渲染的时候由于次序问题出现闪烁这个问题在图形学术语里面有一个名字叫做z-fighting。
![](https://static001.geekbang.org/resource/image/9d/f8/9da3bdd37c5e269b551b63b8ac7510f8.gif "z-fighting 现象")
z-fighting是3D绘图中的一个常见问题所以我再多解释一下。在WebGL中绘制3D物体一般我们开启了深度检测之后引擎会自动计算3D物体的深度让离观察者很近的物体面把离观察者比较远和背对着观察者的物体面遮挡住。那具体是怎么遮挡的呢其实是根据物体在相机空间中的z坐标来判断的。
但有一种特殊情况就是两个面的z坐标相同又有重叠的部分。这时候引擎就可能一会儿先渲染A面过一会儿又先去渲染B面这样渲染出来的内容就出现了“闪烁”现象这就是z-fighting。
![](https://static001.geekbang.org/resource/image/37/8c/3718a4e779004624f44ce952923c348c.jpg "如果A和B深度z坐标相同那么A、B重叠部分渲染次序可能每次不同从而产生z-fighting")
z-fighting有很多解决方法比如可以人为指定一下几何体渲染的次序或者就是让它们的坐标不要完全相同在上面的例子里我们就采用了让坐标不完全相同的处理办法。
最后,为了让实现出来的图形更有趣,我们再增加一个过渡动画,让柱状图的高度从不显示,到慢慢显示出来。
![](https://static001.geekbang.org/resource/image/88/08/887d3e8b4e356b9139934eee7bb70c08.gif)
要实现这个效果我们需要稍微修改一下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物体的绘制、旋转、移动以及给它们添加光照效果、法线贴图让它们能更贴近真实的物体。
说实话,这一篇的内容单看真的不简单。但你认真看了会发现,所有的知识都是环环相扣的,只要有了前几篇的基础,我们再来学肯定可以学会。为了帮助你梳理这一篇的内容,我总结了一张知识脑图放在了下面,你可以看看。
![](https://static001.geekbang.org/resource/image/fd/65/fd8eb76869dc873a816f92ddbd76c265.jpg)
## 小试牛刀
我们今天讲的这个例子你学会了吗你可以用自己的GitHub贡献数据来实现同样的图表也可以稍微修改一下它的样式比如采用不同的颜色、不同的光照效果等等。
另外,课程中的例子是默认获取最近一年到当天的数据,你也可以扩展一下功能,让这个图表可以设置日期范围,根据日期范围来呈现数据。
如果你的GitHub贡献数据不是很多也可以去找相似平台上的数据来实现类似的图表。
今天的实战项目有没有让你体会到可视化的魅力呢?那就快把它分享出去吧!我们下节课再见!
* * *
## 源码
[实现3D可视化图表详细代码](https://github.com/akira-cn/graphics/tree/master/github-contributions)
## 推荐阅读
\[1\] [SpriteJS官网](https://spritejs.org)
\[2\] [d3-api](https://github.com/d3/d3/blob/master/API.md)