17 KiB
27 | 案例:如何实现简单的3D可视化图表?
你好,我是月影。
学了这么多图形学的基础知识和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贡献数据不是很多,也可以去找相似平台上的数据,来实现类似的图表。
今天的实战项目有没有让你体会到可视化的魅力呢?那就快把它分享出去吧!我们下节课再见!
源码
推荐阅读
1
2