# 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对象上创建了一个3D(WebGL)上下文的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.1,y的值本来应该是-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)