139-跟月影学可视化

开篇词 | 不写网页的前端工程师，还能干什么？

你好，我是月影。

可能你知道我，是因为 360 奇舞团，而我是奇虎 360 奇舞团的团长。我们奇舞团是 360 的大前端团队，主要工作是支持公司的一部分业务，包括可视化大屏、技术中台的开发。

把团长这层身份抛开，我还是一个有着 15 年工作经验的前端工程师，其中有很多年在做 JavaScript 开发。如果你也做过 JavaScript 开发，我的这本《JavaScript 王者归来》，你很可能读过。

作为前端工程师，很多人的主要工作就是和网页打交道。那扪心自问一下，写了这么多网页之后，你是不是也遇到过前端职业发展的天花板，想要做些尝试或者突破呢？如果是的话，我建议你试试可视化。

可视化是前端领域中，一个几乎不用写网页的特殊分支，也是最近这几年我的主要研究方向。通过对图形学和渲染引擎底层的深入研究，我作为核心开发者，开发出了跨平台的开源图形系统SpriteJS。同时，这个过程，也让我对可视化领域有了更多自己的想法。

所以今天我想和你聊一聊可视化，说说前端工程师不写网页，到底还能干什么。

前端工程师为什么要学可视化？

首先，我们来说说可视化的两个重要优势。借助它们，你就能明白可视化到底能干什么。

第一，现在很多 C 端或者 B 端的互联网产品都离不开可视化。

C 端最著名的例子就是淘宝“双十一”购物节的可视化大屏。B 端产品的例子那就更多了，许多中后台的管理系统，都会用统计图表来显示系统运行的各种状态。另外，很多企业级应用中的态势感知和指挥调度大屏也是可视化非常常见的应用，以及国家大力推动的智慧城市、智慧社区、智慧生活等等。如果你有关注过，就会发现，可视化在其中都有着非常广泛的应用。

也正因为如此，行业内对可视化工程师的需求也越来越大，许多一线的互联网公司都有可视化相关的招聘需求，但是相关的专业人才却寥寥无几。

第二，可视化能实现很多传统 Web 网页无法实现的效果。

在很多人看来，可视化就是绘制各种图表，学习可视化就是学习如何画饼图、折线图、柱状图、玫瑰图这类图表。如果你也这么认为，那你可能还没有真正理解可视化的本质。实际上，真正的可视化领域，和你想得还真不太一样。

那可视化的本质到底是什么呢？我们先来看一个真实的例子。

我们奇舞团有一个传统，那就是每年年会时，我会给大家现场写一个抽奖程序，所有在场的人共同审查代码，确认没有问题后，开启这一年愉快的年会抽奖活动。

在最近的一次年会上，我写了一个比较另类的抽奖程序，怎么另类呢？

我们知道，一般的抽奖程序，如果用 JavaScript 代码来实现，其核心无非就是产生一个随机数，然后用它来判断究竟谁该获奖。这个程序当然最终要通过 CPU 来执行。但是，我写的这个抽奖程序却是使用GPU来抽奖。我们知道，CPU 是运算单元，GPU 是图形处理单元，那用 GPU 怎么来做抽奖这件事儿呢？

我把这个抽奖程序最终实现的效果放在了下面。你会看到，这个小小的画布上，一共分了 100 块，每一块代表一个员工。第一次抽奖后，代表中奖者的方块会变色，之后再抽奖颜色就会不断叠加。这个程序不仅能保证每个人中奖的概率是公平的，还能保证一个已经中奖的人，不会重复中奖。

你可能会觉得，这个抽奖好像没有什么用处啊。其实并不是，我们要知道，这个程序在理论上可以同时对上百万人（我们可以用一个 1024*1024 的画布，用一个像素点表示一个区块）进行抽奖！虽然，很少有对上百万人同时抽奖的场景，但是，很多程序，比如一些 AI 的程序，其实核心原理类似于这样的抽奖。

在这里，我是用了 GPU 的能力，通过渲染图形来完成的抽奖，这实际上就用到了可视化相关的核心技术。所以你看，可视化可不只是在绘制图表，图表只是可视化工程师日常工作产出的一部分。而就像上面这个例子一样，可视化更重要的是利用 WebGL 这样的图形系统，来更高效地实现更多有趣的图形。尤其是，当你在写一个可视化的项目，要在大屏上用浏览器渲染数以万计的元素时，也许 WebGL 或者 GPU 就是目前唯一可行的方案。

总的来说，通过研究可视化，你能够比较深入地掌握图形学和 WebGL 技术。而这些技术的应用呢，并不局限于可视化领域，它们在游戏、AI 等其他技术领域也有广泛的应用场景。

所以，就算你并不想成为一名可视化工程师，但学习相关的图形、视觉呈现的原理和方法，也能最大化地丰富你的知识面，拓宽你的技术成长路线，让你的技术天花板变得更高。更详细点来说，像视觉呈现技术中也有涉及高级 CSS 原理的部分，所以如果你学会了可视化，也会对你的 CSS 技能有很大的启发和提升。

如何成为一名可视化工程师？

既然可视化这么有用，那想要成为一名优秀的可视化工程师，我们该怎么做呢？

我觉得，一名优秀的可视化工程师，最应当具备的能力就是要根据产品经理和设计师的想法，实现真正贴合用户需求的、灵活多变的、具有视觉震撼力，并且充满创意的优秀可视化作品。

这句话说起来简单，但是要做到，绝不是会使用某个库、会调用某个 API 就能实现的。

比如说，现在我们要把一个图表的线条样式，从实线段改成虚线段，那我们就可能不能使用某个现有的图表库来完成。这是因为，有可能这个图表库中并没有提供参数支持线条的虚线。这时候，我们要么放弃这个需求，要么就得换图表库或者自己去修改图表库的源代码。

如果我们不放弃，想要自己去修改图表库的源代码的话，其实会遇到更多的问题。因为，这些图表应用不仅业务代码相当复杂，图表库本身的源代码也很复杂，所以，不管是修改代码还是后期维护，我们都会面临很多复杂的问题。

再比如说，我团队的一名同学最近在实现一个 3D 可视化大屏，需要渲染大约 70 万个元素，处理各种视觉和交互细节。这可不是调用某某库的某个组件就能轻松实现的，在技术实现的背后有着非常深的图形学、物理学和数学知识。

因此，想要成为一名可视化工程师，我们不能停留在可视化现有的库、框架和工具的使用层面上，必须深入技术栈的底层，充分理解数据，掌握视觉呈现相关的技术和基本原理。打好基础之后，再系统地学习可视化领域的方法论，才能提高解决问题的能力。

也正因为如此，我专门打造了这门课，目的就是帮你打好底层基础，建立可视化知识体系，助力你成为合格的可视化工程师。

如何快速掌握可视化？

首先，想要学好可视化，我们就必须要了解可视化的体系都有什么。这里我画了一张知识结构图，你可以看看。

从图中你可以看到，可视化涉及的内容非常多。光是数据就包括数据分析、数据建模和设计语言，它们每一个都能单独开一门课程了。可想而知，可视化涉及的知识有多广。既然如此，我们该如何从这么多的内容中，快速掌握可视化呢？答案是要有重点地学习。

那如何有重点地学习呢？我们下面分别来分析。

首先，可视化的视觉主要涉及四个部分，分别是理论基础、图形系统、图形库和工具。看起来内容很多，但我们没有必要全都深入学习。而是应该把重点放在理论基础上，因为随着技术的发展，工具会改变，而基础不会。视觉部分的理论基础就是数学和图形学，也就是说，我们必须要学会如何运用数学和图形学知识，来绘制各种各样的几何图形，这样才能实现更加酷炫的视觉效果。

数据这一块呢，虽然相对比较简单，但是内容也不少。如果我们要深入学习细节的话，压力太大，也很难坚持下去。好在，可视化工程师对数据的理解，会随着业务的深入而逐步加深。所以，在数据部分，我们只要理解解决问题的整体构思和方法论，知道如何从一个可视化工程师的角度去理解和运用数据，就已经足够为我们未来的深入研究，打开一扇大门了。

最后，谈可视化离不开性能，性能往往决定了可视化项目最终所能呈现的整体感官效果。怎么理解呢？举个例子，当你想要通过一段代码，来实现非常具有视觉冲击力的效果时，却发现即使 GPU 和 CPU 都以最大功率运转了，还是没能把你构思的画面，在屏幕上流畅地显示出来。这是为什么呢？

这很大可能是因为性能没有达到最优。也许是你的技术选型和代码实现在渲染方面遇到了瓶颈；也许是你过于关注渲染本身，而忽视了计算；也有可能是核心算法有改进空间。有时候，我们花大力气去调优渲染机制，其实不如换一种更加合理的算法取得的收益更大。上面这些呢，都有可能是导致性能不佳的元凶。因此，在性能方面，我们应该学习的，是如何快速找到合理的性能优化点。

好了，学习可视化的重点我们说完了，那到底该怎么学呢？我结合这些重点，梳理了一张可视化的学习路径图。接下来，我就结合这个路径图来说说，我们的课程到底是怎么设计的。

我们的课程是如何设计的？

我把整个课程分为了五个部分，分别是图形基础、数学基础、视觉呈现、性能优化和数据驱动。下面，我们来详细说说。

第一部分，图形基础。首先，我会带你熟悉 HTML/CSS、SVG、Canvas2D 和 WebGL 这四种图形系统，并会结合一些简单的实际案例，帮助你全面掌握它们的基本用法、原则、优点和局限性。通过这部分的内容，你能对图形系统有一个大体的认知，并且掌握一些必须的绘图技巧，从而能够更好地学习后续的课程。

第二部分，数学基础。在这一部分里，我希望通过对向量和矩阵运算、参数方程、三角剖分和仿射变换等内容的深入讲解以及综合运用，来帮助你建立一套通用的数学知识体系，能适用于所有图形系统，以此来解决可视化图形呈现中的大部分问题。

第三部分，视觉呈现。视觉呈现效果是衡量可视化效果的重要指标，因此，实现各种视觉效果的能力，可以说是可视化工程师的核心竞争力。视觉呈现也是可视化中非常重要的内容，那这一部分我会分两个阶段来讲：在基础阶段，我们主要讨论如何以像素化的方式处理图案，来实现各种细节效果；在高级阶段，我们讨论动画、3D 和交互等更深入的话题。并且，我还会借助美颜、图片处理和视觉特效等实际例子，来教你怎样更好地应用数学和图形学知识，帮你全面提升视觉呈现的能力。

第四部分，性能优化。这一部分，我会讲解 WebGL 渲染复杂的 2D、3D 模型的方法，让你了解可视化高性能渲染的技术思路，比较轻松地找到可视化应用中的性能瓶颈并着手优化。再结合我总结出的一些成熟的方法论，你就能在实现可视化项目的时候，解决大规模数据批量渲染的性能瓶颈问题，来实现更酷炫的视觉效果了。

最后一部分，我们将重点讨论数据驱动。通常来说，数据的格式和量级决定了图形系统的选择和渲染方式。一个成熟的可视化系统，应该包括数据引擎和渲染引擎两部分。所以，对于可视化而言，数据和渲染同样重要。那么在这一部分中，我会结合 3D 柱状图、3D 层级结构图、3D 音乐可视化等案例，讲解数据处理的技巧，真正将数据和视觉呈现结合起来，实现具有科技感的 3D 可视化大屏效果，并最终形成完整的可视化解决方案。

以上，就是这门课我想分享给你的全部内容了。总的来说，我希望通过这门课的学习和讨论，你能真正系统地掌握可视化开发的原理和方法，理解如何处理数据，学会各种视觉呈现方式，最终创建出自己的可视化工具集，并且用它来更高效地解决可视化开发中的问题。

其实到这儿，课程的核心内容就说完了。不过，在学习上，我还想再啰嗦几句，要掌握这些内容，肯定需要你付出非常多的耐心和毅力，并且保持积极的思考。

课程中难免有部分内容比较抽象和枯燥，这也正是我们最容易放弃的地方。所以，在课程中，我也会尽量用一些比较浅显而有趣的例子把内容串起来。同时，为了帮助你理解，我会添加大量的代码。我希望你能真正在浏览器中运行这些代码，如果遇到问题，也非常欢迎你在留言区和我一起讨论。

最后，我是一名工程师，喜欢写代码，我认为很多道理通过代码来讲是最直接的。如果你也和我一样，那就快点加入可视化的学习，和我一起享受“创造世界”的乐趣吧！

源代码

抽奖程序开源代码及在线演示

推荐阅读

[1] Canvas API 的官方文档

[2] SVG 的官方文档

[3] WebGL API 的官方文档

预习 | Web前端与可视化到底有什么区别？

你好，我是月影。在课程正式开始之前，我想先和你聊聊可视化是什么，Web 前端和可视化的区别，以及可视化领域中非常重要的工具。了解了这些基本的东西，对我们的学习是非常有帮助的。

前段时间因为新型冠状病毒，我们每天都在关注疫情信息。不知道你有没有注意过这些疫情信息的展示方式。

来源：北京大学可视化与可视分析实验室

我们看到的疫情图大概都会通过上面这个信息图的样子展示出来。这种信息图与普通的网页看上去差别非常大，我们没办法用传统的 Web 开发技术实现这样的“网页”。没错，这是一个与传统 Web 开发完全不一样的领域，叫做数据可视化（Data Visualization）。

其实，除了“疫情地图”之外，我们平时见到的很多东西，都是通过数据可视化来实现的。比如，每年淘宝“双十一”的可视化数据大屏、各种平台的年度账单等等。

那你可能要问了，可视化到底是什么呢？

如果要给可视化下一个定义，我觉得应该是这样的：可视化是将数据组织成易于为人所理解和认知的结构，然后用图形的方式形象地呈现出来的理论、方法和技术。实现可视化有两个关键要素，一个是数据，另一个是图形。如果要考虑在计算机上呈现，那还要加上交互。

Web 前端与可视化有什么区别？

据我所知，很多同学在工作中因为产品需求要呈现图表，而不知不觉从 Web 开发进入了可视化领域。但因为不了解它们之间的核心区别，或多或少都会遇到一些棘手的问题。

那可视化和 Web 前端究竟有什么区别呢？

我们先从工作内容来看，前端工程师主要负责处理内容呈现和用户交互。可视化的数据呈现，尤其是在 Web 端的呈现，也属于前端工程师的范畴。但是，与传统的前端开发相比，可视化开发的工具、思路和方法，又和 Web 有着比较大的区别。

首先是技术栈的不同。Web 开发主要以 HTML 来描述结构，以 CSS 来描述表现，以 JavaScript 来描述行为。而可视化则较少涉及 HTML 和 CSS，它更多地要同浏览器的 Canvas、SVG、WebGL 等其他图形 API 打交道。这是因为，Web 开发以呈现块状内容为主，所以 HTML 是更合适的技术。而可视化开发因为需要呈现各种各样的形状、结构，所以，形状更丰富的 SVG 以及更底层的 Canvas2D 和 WebGL 就是更合适的技术了。

Web前端和可视化基础技术栈

其次，Web 开发着重于处理普通的文本和多媒体信息，渲染普通的、易于阅读的文本和多媒体内容，而可视化开发则着重于处理结构化数据，渲染各种相对复杂的图表和图形元素。两者针对的信息特征和对图形渲染的要求有所不同，所以，在细节处理上也有比较大的差异。

这么说比较抽象，我来举个例子。我们在使用 ECharts 这样现成的图标库开发图表的时候，往往会遇到一些产品需求不能很好被满足的情况。比如，如果想要把图表的默认布局方式从 absolute 改变成其他方式、把坐标轴指示线的样式换成虚线、把配色换成线性渐变，或者想要修改地图中某个点的点击范围。虽然有的问题，通过修改参数配置（ECharts 的每种图表可能有数十种不同的配置项）确实能解决，但还是有一些问题必须要修改库的源码才能解决。

因此，像 ECharts 等现成的图表库，尽管功能强大、配置复杂，但是在样式细节控制上，仍然无法做到如 CSS 那般灵活。

除此之外，可视化要处理更多偏视觉方面的细节信息，尤其是在要呈现的数据细节比较丰富的时候，可能要精确地呈现大小、距离、角度、高度、光线、阴影等等。这些细节的处理，都需要我们对图形绘制有更加精确的控制。因此，我们需要较深入地掌握图形学理论知识，了解并熟悉专业的图形库和绘图的工具。简而言之，就是 Web 开发的前端主要还是关注内容和样式，图形的渲染和绘制是由浏览器底层来完成的，而可视化前端则可能要深入底层渲染层，去真正地控制图形的绘制和细节的呈现。

尽管 Web 前端与可视化在工具、思路和方法上有一些区别，但前端开发的同学想要学习可视化，门槛并不高，主要原因有三点。

其一，可视化与 Web 前端一样，最终都是以图像呈现在浏览器上，因此有许多通用的方法论。比如，两者都要涉及 DOM、都要处理浏览器事件、都采用同样的颜色表达方式和同样的资源请求方法等等。

其二，二者都使用 JavaScript，而且都是浏览器端的 JavaScript。所以，就 JavaScript 的应用而言，这一块差别并不大。不过，可视化应用面对的数据和渲染的图形元素都比传统的 Web 应用更复杂一些，所以也就更加依赖 JavaScript 一些。

其三，Web 前端领域有许多成熟的工具，包括响应式框架（比如，三大框架 Vue、React、Angular）、设计系统（比如 Ant Design、ElementUI）、函数库（比如，Underscore、Lodash）等等。与 Web 前端一样，可视化领域也有丰富的 JavaScript 工具和活跃的生态，通常这些“开箱即用”的工具，就能够帮助我们完成可视化开发。

因此，只要善于使用这些成熟的可视化工具，我们就能高效率地完成手头的工作。所以，这些工具对可视化的学习和应用来说非常重要。那接下来，我就带你了解一下，可视化领域中都有哪些常用的工具。

可视化领域的工具

可视化领域经过多年的发展，有非常多丰富的工具。我们可以把这些工具大体上分为四类，分别是：专业呈现各种类型图表的图表库；专业处理地图、地理位置的可视化地理库；专业处理视觉呈现的渲染库；以及处理数据的数据驱动框架。

下面，我就分别来介绍一下这些重要的库。

首先是图表库。可视化应用通常需要绘制大量的图表，比如，柱状图、折线图、饼图，还有一些简单的平面地图等等。图表库能够帮助我们快速把它们绘制出来。

社区中有许多优秀的开源图表库，比如我们前面说的 ECharts，或者类似的chartlist、chart.js等等，它们都属于图表库。

如果要绘制更加复杂的地图，比如，一座城市的交通线路和建筑物三维模型，或者一个园区的立体建筑模型等等，我们可能要依赖专业的 GIS 地图库。社区中比较成熟的 GIS 库也不少，比较常见的像Mapbox、Leaflet、Deck.gl、CesiumJS等等。

Deck.gl绘制的3D地图效果

如果要绘制其他更灵活的图形、图像或者物理模型，我们常用的一些图表库就不一定能完成了。这个时候，我们可以用ThreesJS、SpriteJS这样比较通用的渲染库（实际上图表库或 GIS 地图库本身底层渲染也基于这些渲染库）。我们可以选择通用的图形库，比如，2D 渲染可以选择 SpriteJS，3D 渲染可以选择 ThreeJS、BabylonJS 以及 SpriteJS3D 扩展等等。

SpriteJS

ThreeJS

除了这些库之外，还有一类比较特殊的库，比如D3.js，它属于数据驱动框架。那什么是数据驱动框架呢？这是一种特殊的库，它们更专注于处理数据的组织形式，而将数据呈现交给更底层的图形系统（DOM、SVG、Canvas）或通用图形库（SpriteJS、ThreeJS）去完成。

D3.js 与图表库一样，都能完成可视化项目中的各种图表展现，但是它们之间也有区别。下面，我来说说它们各自的优势和劣势。

ECharts 等大部分图表库会提供封装好的图表类型，我们只需要简单配置一下参数就可以使用。但正因为如此，图表能够表现的形式也会被预设的图表类型和封装好的参数所固定了。如果我们想做一些非常个性化的视觉呈现形式，用图表库来做，相对就比较困难。而 D3.js 因为只关注数据的组织形式，将具体的渲染交给底层去做，所以更加灵活，扩展起来也很方便。但相对地，就不像其他的图表库一样，拥有完整的封装了，使用的门槛也就相对高一些。

用d3绘制的等高线

D3.js 是可视化领域一个很重要的库，关于它的具体运作机制比较复杂，我们会在数据篇里详细来讲。

要点总结

这一节课，我们重点讲了 Web 前端和可视化的区别和共同之处。

区别主要有两方面，首先是技术栈的不同。Web 开发主要会用到 HTML 和 CSS，而可视化则较少涉及 HTML 和 CSS，它更多地要同浏览器的 Canvas、SVG、WebGL 等其他图形 API 打交道。其次，Web 开发着重于处理普通的文本和多媒体信息，渲染普通的、易于阅读的文本和多媒体内容，而可视化开发则着重于处理结构化数据，有时需要深入渲染引擎层，从而控制细节，让浏览器渲染出各种相对复杂的图表和图形元素。

不过，如果你原本负责前端开发，想要学习可视化门槛并不高，因为它们之间有很多共通的方法论，而且都依赖于 JavaScript。并且，可视化也有很多成熟的工具和库可以供我们使用。

其实，今天说了这么多，我就是想告诉你，可视化虽然涉及的内容非常多，其核心技术与 Web 开发的前端工程师的侧重点有所不同，但是其核心思想和方法论与 Web 前端是相通的，很多技术也是相辅相成、相互启发的。

因此，前端工程师学习可视化并没有太大的困难，甚至学习这些和可视化相关的图形学理论和技术，也能够提升 Web 开发的前端技术。

一起聊一聊

你为什么想要学习可视化呢？你觉得在学习的过程中有什么难点？

欢迎你在留言区把你的想法和疑惑分享出来，我会尽力为你解答。如果这一节课对你有帮助，也欢迎你把它分享给你的朋友们！

01 | 浏览器中实现可视化的四种方式

你好，我是月影。

上一节课我们了解了什么是可视化。可视化用一句话来说，本质上就是将数据信息组织起来后，以图形的方式呈现出来。在 Web 上，图形通常是通过浏览器绘制的。现代浏览器是一个复杂的系统，其中负责绘制图形的部分是渲染引擎。渲染引擎绘制图形的方式，我总结了一下，大体上有 4 种。

第 1 种是传统的 HTML+CSS。这种方式通常用来呈现普通的 Web 网页。

第 2 种是使用 SVG。SVG 和传统的 HTML+CSS 的绘图方式差别不大。只不过，HTML 元素在绘制矢量图形方面的能力有些不足（我们后面会讲到），而 SVG 恰好弥补了这方面的缺陷。

第 3 种是使用 Canvas2D。这是浏览器提供的 Canvas API 中的其中一种上下文，使用它可以非常方便地绘制出基础的几何图形。在可视化中，Canvas 比较常用，下一节课我们会学习它的基本用法。

第 4 种是使用 WebGL。这是浏览器提供的 Canvas API 中的另一种上下文，它是 OpenGL ES 规范在 Web 端的实现。我们可以通过它，用 GPU 渲染各种复杂的 2D 和 3D 图形。值得一提的是，WebGL 利用了 GPU 并行处理的特性，这让它在处理大量数据展现的时候，性能大大优于前 3 种绘图方式。因此，在可视化的应用中，一些数据量大、视觉效果要求高的特殊场景，使用 WebGL 渲染是一种比较合适的选择。

这 4 种方式各有利弊，今天我就从宏观层面带你了解这些图形系统，为我们后面更深入的学习打好基础。

方式一：HTML+CSS

与传统的 Web 应用相比，可视化项目，尤其是 PC 端的可视化大屏展现，不只是使用 HTML 与 CSS 相对较少，而且使用方式也不太一样。于是，有些同学就会认为，可视化只能使用 SVG、Canvas 这些方式，不能使用 HTML 与 CSS。当然了，这个想法是不对。具体的原因是什么呢？我一起来看看。

实际上，现代浏览器的 HTML、CSS 表现能力很强大，完全可以实现常规的图表展现，比如，我们常见的柱状图、饼图和折线图。

虽然我们后面的课程会主要使用 Canvas 和 WebGL 绘图，少数会涉及部分 CSS。但是，你可不要觉得它不重要。为啥呢？理由有两个：

一些简单的可视化图表，用 CSS 来实现很有好处，既能简化开发，又不需要引入额外的库，可以节省资源，提高网页打开的速度。

理解 CSS 的绘图思想对于可视化也是很有帮助的，比如，CSS 的很多理论就和视觉相关，可视化中都可以拿来借鉴。

所以呢，这一节里我们多讲一点，你一定要好好听。接下来，我们就来说一说，CSS 是如何实现常规图表的。

1. HTML 与 CSS 是如何实现可视化的？

用 CSS 实现柱状图其实很简单，原理就是使用网格布局（Grid Layout）加上线性渐变（Linear-gradient），我就不多说了，你可以直接看我这里给出的 CSS 代码。

用HTML+CSS绘制的柱状图

/**
   dataset = {
     current: [15, 11, 17, 25, 37],
     total: [25, 26, 40, 45, 68],
   }
 */
.bargraph {
  display: grid;
  width: 150px;
  height: 100px;
  padding: 10px;
  transform: scaleY(3);
  grid-template-columns: repeat(5, 20%);
}
.bargraph div {
  margin: 0 2px;
}
.bargraph div:nth-child(1) {
 background: linear-gradient(to bottom, transparent 75%, #37c 0, #37c 85%, #3c7 0);
}
.bargraph div:nth-child(2) {
 background: linear-gradient(to bottom, transparent 74%, #37c 0, #37c 89%, #3c7 0);
}
.bargraph div:nth-child(3) {
 background: linear-gradient(to bottom, transparent 60%, #37c 0, #37c 83%, #3c7 0);
}
.bargraph div:nth-child(4) {
 background: linear-gradient(to bottom, transparent 55%, #37c 0, #37c 75%, #3c7 0);
}
.bargraph div:nth-child(5) {
 background: linear-gradient(to bottom, transparent 32%, #37c 0, #37c 63%, #3c7 0);
}

而要实现饼图，我们可以使用圆锥渐变，方法也很简单，你直接看代码就可以理解。

使用圆锥渐变绘制的饼图

.piegraph {
  display: inline-block;
  width: 250px;
  height: 250px;
  border-radius: 50%;
  background-image: conic-gradient(#37c 30deg, #3c7 30deg, #3c7 65deg, orange 65deg, orange 110deg, #f73 110deg, #f73 200deg, #ccc 200deg);
}

柱状图和饼图都比较简单，所以我带你快速过了一下。除此之外，我们用 HTML 和 CSS 也可以实现折线图。

我们可以用高度很小的 Div 元素来模拟线段，然后用 transform 改变角度和位置，这样就能拼成折线图了。 另外，如果使用 clip-path 这样的高级属性，我们还能实现更复杂的图表，比如，用不同的颜色表示两个不同折线的面积。

折线图和面积图

实际上很多常见的可视化图表我们都可以用 HTML 和 CSS 来实现，不需要用其他的绘图方式。但是，为什么在可视化领域很少有人直接用 HTML 和 CSS 来绘制图表呢？这主要是因为，使用 HTML 和 CSS 绘图，有 2 个缺点。

2. 用 HTML+CSS 实现可视化的缺点

首先，HTML 和 CSS 主要还是为网页布局而创造的，使用它们虽然能绘制可视化图表，但是绘制的方式并不简洁。这是因为，从 CSS 代码里，我们很难看出数据与图形的对应关系，有很多换算也需要开发人员自己来做。这样一来，一旦图表或数据发生改动，就需要我们重新计算，维护起来会很麻烦。

其次，HTML 和 CSS 作为浏览器渲染引擎的一部分，为了完成页面渲染的工作，除了绘制图形外，还要做很多额外的工作。比如说，浏览器的渲染引擎在工作时，要先解析 HTML、SVG、CSS，构建 DOM 树、RenderObject 树和 RenderLayer 树，然后用 HTML（或 SVG）绘图。当图形发生变化时，我们很可能要重新执行全部的工作，这样的性能开销是非常大的。

而且传统的 Web 开发，因为涉及 UI 构建和内容组织，所以这些额外的解析和构建工作都是必须做的。而可视化与传统网页不同，它不太需要复杂的布局，更多的工作是在绘图和数据计算。所以，对于可视化来说，这些额外的工作反而相当于白白消耗了性能。

因此，相比于 HTML 和 CSS，Canvas2D 和 WebGL 更适合去做可视化这一领域的绘图工作。它们的绘图 API 能够直接操作绘图上下文，一般不涉及引擎的其他部分，在重绘图像时，也不会发生重新解析文档和构建结构的过程，开销要小很多。

图形系统与浏览器渲染引擎工作对比

方式二：SVG

在介绍 Canvas2D 和 WebGL 之前，我们先来说一说 SVG。现代浏览器支持 SVG（Scalable Vector Graphics，可缩放矢量图），SVG 是一种基于 XML 语法的图像格式，可以用图片（img 元素）的 src 属性加载。而且，浏览器更强大的是，它还可以内嵌 SVG 标签，并且像操作普通的 HTML 元素一样，利用 DOM API 操作 SVG 元素。甚至，CSS 也可以作用于内嵌的 SVG 元素。

比如，上面的柱状图，如果用 SVG 实现的话，我们可以用如下所示的代码来实现：

 <!--
      dataset = {
        total: [25, 26, 40, 45, 68],
        current: [15, 11, 17, 25, 37],
      }
  -->
  <svg xmlns="http://www.w3.org/2000/svg" width="120px" height="240px" viewBox="0 0 60 100">
    <g transform="translate(0, 100) scale(1, -1)">
      <g>
        <rect x="1" y="0" width="10" height="25" fill="#37c"/>
        <rect x="13" y="0" width="10" height="26" fill="#37c"/>
        <rect x="25" y="0" width="10" height="40" fill="#37c"/>
        <rect x="37" y="0" width="10" height="45" fill="#37c"/>
        <rect x="49" y="0" width="10" height="68" fill="#37c"/>
      </g>
      <g>
        <rect x="1" y="0" width="10" height="15" fill="#3c7"/>
        <rect x="13" y="0" width="10" height="11" fill="#3c7"/>
        <rect x="25" y="0" width="10" height="17" fill="#3c7"/>
        <rect x="37" y="0" width="10" height="25" fill="#3c7"/>
        <rect x="49" y="0" width="10" height="37" fill="#3c7"/>
      </g>
    </g>
  </svg>

从上面的 SVG 代码中，我们可以一目了然地看出，数据 total 和 current 分别对应 SVG 中两个 g 元素下的 rect 元素的高度。也就是说，元素的属性和数值可以直接对应起来。而 CSS 代码并不能直观体现出数据的数值，需要进行 CSS 规则转换。具体如下图所示：

在上面这段 SVG 代码中，g 表示分组，rect 表示绘制一个矩形元素。除了 rect 外，SVG 还提供了丰富的图形元素，可以绘制矩形、圆弧、椭圆、多边形和贝塞尔曲线等等。由于 SVG 比较复杂，我们会在第 4 节课专门介绍，如何用 SVG 绘制可视化图表。在那之前，你也可以通过MDN 官方文档，来学习更多的 SVG 的 API。

SVG 绘制图表与 HTML 和 CSS 绘制图表的方式差别不大，只不过是将 HTML 标签替换成 SVG 标签，运用了一些 SVG 支持的特殊属性。

HTML 的不足之处在于 HTML 元素的形状一般是矩形，虽然用 CSS 辅助，也能够绘制出各种其它形状的图形，甚至不规则图形，但是总体而言还是非常麻烦的。而 SVG 则弥补了这方面的不足，让不规则图形的绘制变得更简单了。因此，用 SVG 绘图比用 HTML 和 CSS 要便利得多。

但是，SVG 图表也有缺点。在渲染引擎中，SVG 元素和 HTML 元素一样，在输出图形前都需要经过引擎的解析、布局计算和渲染树生成。而且，一个 SVG 元素只表示一种基本图形，如果展示的数据很复杂，生成图形的 SVG 元素就会很多。这样一来，大量的 SVG 元素不仅会占用很多内存空间，还会增加引擎、布局计算和渲染树生成的开销，降低性能，减慢渲染速度。这也就注定了 SVG 只适合应用于元素较少的简单可视化场景。

方式三：Canvas2D

除了 SVG，使用 Canvas2D 上下文来绘制可视化图表也很方便，但是在绘制方式上，Canvas2D 和 HTML/CSS、SVG 又有些不同。

无论是使用 HTML/CSS 还是 SVG，它们都属于声明式绘图系统，也就是我们根据数据创建各种不同的图形元素（或者 CSS 规则），然后利用浏览器渲染引擎解析它们并渲染出来。但是 Canvas2D 不同，它是浏览器提供的一种可以直接用代码在一块平面的“画布”上绘制图形的 API，使用它来绘图更像是传统的“编写代码”，简单来说就是调用绘图指令，然后引擎直接在页面上绘制图形。这是一种指令式的绘图系统。

那 Canvas 到底是怎么绘制可视化图表的呢？我们一起来看。

首先，Canvas 元素在浏览器上创造一个空白的画布，通过提供渲染上下文，赋予我们绘制内容的能力。然后，我们只需要调用渲染上下文，设置各种属性，然后调用绘图指令完成输出，就能在画布上呈现各种各样的图形了。

为了实现更加复杂的效果，Canvas 还提供了非常丰富的设置和绘图 API，我们可以通过操作上下文，来改变填充和描边颜色，对画布进行几何变换，调用各种绘图指令，然后将绘制的图形输出到画布上。

总结来说，Canvas 能够直接操作绘图上下文，不需要经过 HTML、CSS 解析、构建渲染树、布局等一系列操作。因此单纯绘图的话，Canvas 比 HTML/CSS 和 SVG 要快得多。

但是，因为 HTML 和 SVG 一个元素对应一个基本图形，所以我们可以很方便地操作它们，比如在柱状图的某个柱子上注册点击事件。而同样的功能在 Canvas 上就比较难实现了，因为对于 Canvas 来说，绘制整个柱状图的过程就是一系列指令的执行过程，其中并没有区分“A 柱子”、“B 柱子”，这让我们很难单独对 Canvas 绘图的局部进行控制。不过，这并不代表我们就不能控制 Canvas 的局部了。实际上，通过数学计算我们是可以通过定位的方式来获取局部图形的，在后续的课程中我们会解决这个问题。

这里有一点需要你注意，Canvas 和 SVG 的使用也不是非此即彼的，它们可以结合使用。因为 SVG 作为一种图形格式，也可以作为 image 元素绘制到 Canvas 中。举个例子，我们可以先使用 SVG 生成某些图形，然后用 Canvas 来渲染。这样，我们就既可以享受 SVG 的便利性，又可以享受 Canvas 的高性能了。

方式四：WebGL

WebGL 绘制比前三种方式要复杂一些，因为 WebGL 是基于 OpenGL ES 规范的浏览器实现的，API 相对更底层，使用起来不如前三种那么简单直接。关于 WebGL 的使用内容，我会在 3D 篇详细来说。

一般情况下，Canvas2D 绘制图形的性能已经足够高了，但是在三种情况下我们有必要直接操作更强大的 GPU 来实现绘图。

第一种情况，如果我们要绘制的图形数量非常多，比如有多达数万个几何图形需要绘制，而且它们的位置和方向都在不停地变化，那我们即使用 Canvas2D 绘制了，性能还是会达到瓶颈。这个时候，我们就需要使用 GPU 能力，直接用 WebGL 来绘制。

第二种情况，如果我们要对较大图像的细节做像素处理，比如，实现物体的光影、流体效果和一些复杂的像素滤镜。由于这些效果往往要精准地改变一个图像全局或局部区域的所有像素点，要计算的像素点数量非常的多（一般是数十万甚至上百万数量级的）。这时，即使采用 Canvas2D 操作，也会达到性能瓶颈，所以我们也要用 WebGL 来绘制。

第三种情况是绘制 3D 物体。因为 WebGL 内置了对 3D 物体的投影、深度检测等特性，所以用它来渲染 3D 物体就不需要我们自己对坐标做底层的处理了。那在这种情况下，WebGL 无论是在使用上还是性能上都有很大优势。

要点总结

今天，我们介绍了四种可视化实现方式和它们的优缺点。

HTML+CSS 的优点是方便，不需要第三方依赖，甚至不需要 JavaScript 代码。如果我们要绘制少量常见的图表，可以直接采用 HTML 和 CSS。它的缺点是 CSS 属性不能直观体现数据，绘制起来也相对麻烦，图形复杂会导致 HTML 元素多，而消耗性能。

SVG 是对 HTML/CSS 的增强，弥补了 HTML 绘制不规则图形的能力。它通过属性设置图形，可以直观地体现数据，使用起来非常方便。但是 SVG 也有和 HTML/CSS 同样的问题，图形复杂时需要的 SVG 元素太多，也非常消耗性能。

Canvas2D 是浏览器提供的简便快捷的指令式图形系统，它通过一些简单的指令就能快速绘制出复杂的图形。由于它直接操作绘图上下文，因此没有 HTML/CSS 和 SVG 绘图因为元素多导致消耗性能的问题，性能要比前两者快得多。但是如果要绘制的图形太多，或者处理大量的像素计算时，Canvas2D 依然会遇到性能瓶颈。

WebGL 是浏览器提供的功能强大的绘图系统，它使用比较复杂，但是功能强大，能够充分利用 GPU 并行计算的能力，来快速、精准地操作图像的像素，在同一时间完成数十万或数百万次计算。另外，它还内置了对 3D 物体的投影、深度检测等处理，这让它更适合绘制 3D 场景。

知道了这些优缺点，在实际面对可视化需求的时候，我们就可以根据具体项目的特点来选择合适的方案实现可视化了。那具体来说，我们应该怎么选择呢？我这里梳理了一张技术方案的选择图，你可以看一看。

小试牛刀

我们在文中实现了 SVG 版本的柱状图，你可以尝试用 SVG 实现同 HTML/CSS 版本一样的饼图、折线图和面积图，体会一下使用 SVG 实现和 HTML/CSS 实现的不同点。

另外，下一节课我们会介绍 Canvas2D 绘制可视化图表，你可以提前预习一下，试一试能否用 Canvas2D 来绘制文中的柱状图。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

02 | 指令式绘图系统：如何用Canvas绘制层次关系图？

你好，我是月影。

Canvas 是可视化领域里非常重要且常用的图形系统，在可视化项目中，它能够帮助我们将数据内容以几何图形的形式，非常方便地呈现出来。

今天，我们就在上一节课的基础上对 Canvas 进行稍微深入一些的介绍，来学习一下 Canvas 绘制基本几何图形的方法。

我主要会讲解如何用它的 2D 上下文来完成绘图，不过，我不会去讲和它有关的所有 Api，重点只是希望通过调用一些常用的 API 能给你讲清楚，Canvas2D 能做什么、要怎么使用，以及它的局限性是什么。最后，我还会带你用 Canvas 绘制一个表示省市关系的层次关系图（Hierarchy Graph）。希望通过这个可视化的例子，能帮你实践并掌握 Canvas 的用法。

在我们后面的课程中，基本上 70~80% 的图都可以用 Canvas 来绘制，所以其重要性不言而喻。话不多说，让我们正式开始今天的内容吧！

如何用 Canvas 绘制几何图形？

首先，我们通过一个绘制红色正方形的简单例子，来讲一讲 Canvas 的基本用法。

1. Canvas 元素和 2D 上下文

对浏览器来说，Canvas 也是 HTML 元素，我们可以用 canvas 标签将它插入到 HTML 内容中。比如，我们可以在 body 里插入一个宽、高分别为 512 的 canvas 元素。

<body>
  <canvas width="512" height="512"></canvas>
</body>

这里有一点需要特别注意，Canvas 元素上的 width 和 height 属性不等同于 Canvas 元素的 CSS 样式的属性。这是因为，CSS 属性中的宽高影响 Canvas 在页面上呈现的大小，而 HTML 属性中的宽高则决定了 Canvas 的坐标系。为了区分它们，我们称 Canvas 的 HTML 属性宽高为画布宽高，CSS 样式宽高为样式宽高。

在实际绘制的时候，如果我们不设置 Canvas 元素的样式，那么 Canvas 元素的画布宽高就会等于它的样式宽高的像素值，也就是 512px。

而如果这个时候，我们通过 CSS 设置其他的值指定了它的样式宽高。比如说，我们将样式宽高设置成 256px，那么它实际的画布宽高就是样式宽高的两倍了。代码和效果如下：

canvas {
  width: 256px;
  height: 256px;
}

将canvas的样式宽高设为256px，画布宽高设为512px

因为画布宽高决定了可视区域的坐标范围，所以 Canvas 将画布宽高和样式宽高分开的做法，能更方便地适配不同的显示设备。

比如，我们要在画布宽高为 500*500 的 Canvas 画布上，绘制一个居中显示的 100*100 宽高的正方形。我们只要将它的坐标设置在 x = 200, y = 200 处即可。这样，不论这个 Canvas 以多大的尺寸显示在各种设备上，我们的代码都不需要修改。否则，如果 Canvas 的坐标范围（画布宽高）跟着样式宽高变化，那么当屏幕尺寸改变的时候，我们就要重新计算需要绘制的图形的所有坐标，这对于我们来说将会是一场“灾难”。

在画布宽高为500X500的Canvas上居中绘制一个100`*`100的正方形

2. Canvas 的坐标系

好了，Canvas 画布已经设置好了，接下来我们来了解一下 Canvas 的坐标系。

Canvas 的坐标系和浏览器窗口的坐标系类似，它们都默认左上角为坐标原点，x 轴水平向右，y 轴垂直向下。那在我们设置好的画布宽高为 512*512 的 Canvas 画布中，它的左上角坐标值为（0,0），右下角坐标值为（512,512） 。这意味着，坐标（0,0）到（512,512）之间的所有图形，都会被浏览器渲染到画布上。

canvas坐标系

注意，上图中这个坐标系的 y 轴向下，意味着这个坐标系和笛卡尔坐标系不同，它们的 y 轴是相反的。那在实际应用的时候，如果我们想绘制一个向右上平抛小球的动画，它的抛物线轨迹，在 Canvas 上绘制出来的方向就是向下凹的。

Canvas坐标系

另外，如果我们再考虑旋转或者三维运动，这个坐标系就会变成“左手系”。而左手系的平面法向量的方向和旋转方向，和我们熟悉的右手系相反。如果你现在还不能完全理解它们的区别，那也没关系，在实际应用的时候，我会再讲的，这里你只需要有一个大体印象就可以了。

左手系和右手系

3. 利用 Canvas 绘制几何图形

有了坐标系，我们就可以将几何图形绘制到 Canvas 上了。具体的步骤可以分为两步，分别是获取 Canvas 上下文和利用 Canvas 上下文绘制图形。下面，我们一一来看。

第一步，获取 Canvas 上下文。

那在 JavaScript 中，我们要获取 Canvas 上下文也需要两个步骤。首先是获取 Canvas 元素。因为 Canvas 元素就是 HTML 文档中的 canvas 标签，所以，我们可以通过 DOM API 获取它，代码如下：

const canvas = document.querySelector('canvas');

获取了 canvas 元素后，我们就可以通过 getContext 方法拿到它的上下文对象。具体的操作就是，我们调用 canvas.getContext 传入参数 2d。

const context = canvas.getContext('2d');

有了 2d 上下文，我们就可以开始绘制图形了。

第二步，用 Canvas 上下文绘制图形。

我们拿到的 context 对象上会有许多 API，它们大体上可以分为两类：一类是设置状态的 API，可以设置或改变当前的绘图状态，比如，改变要绘制图形的颜色、线宽、坐标变换等等；另一类是绘制指令 API，用来绘制不同形状的几何图形。

如何使用这些 API 呢？我来举个例子，假设我们要在画布的中心位置绘制一个 100*100 的红色正方形。那我们该怎么做呢？

首先，我们要通过计算得到 Canvas 画布的中心点。前面我们已经说过，Canvas 坐标系的左上角坐标是 (0,0)，右下角是 Canvas 的画布坐标，即 （canvas.width,canvas.height），所以画布的中心点坐标是（0.5 *canvas.width, 0.5 * canvas.height）。

如果我们要在中心点绘制一个 100 * 100 的正方形，那对应的 Canvas 指令是：

context.rect(0.5 * canvas.width, 0.5 * canvas.height, 100, 100);

其中，context.rect 是绘制矩形的 Canvas 指令，它的四个参数分别表示要绘制的矩形的 x、y 坐标和宽高。在这里我们要绘制的正方形宽高都是 100，所以后两个参数是 100 和 100。

那在实际绘制之前，我们还有一些工作要做。我要将正方形填充成红色，这一步通过调用 context.fillStyle 指令就可以完成。然后，我们要调用一个 beginPath 的指令，告诉 Canvas 我们现在绘制的路径。接着，才是调用 rect 指令完成绘图。最后，我们还要调用 fill 指令，将绘制的内容真正输出到画布中。这样我们就完整了绘制，绘制的效果和代码如下：

const rectSize = [100, 100];
context.fillStyle = 'red';
context.beginPath();
context.rect(0.5 * canvas.width, 0.5 * canvas.height, ...rectSize);
context.fill();

但是，看到上面这张图，我们发现了一个问题：正方形并没有居于画布的正中心。这是为什么呢？

你可以回想一下我们刚才的操作，在绘制正方形的时候，我们将 rect 指令的参数 x、y 设为画布宽高的一半。而 rect 指令的 x、y 的值表示的是，我们要绘制出的矩形的左上角坐标而不是中心点坐标，所以绘制出来的正方形自然就不在正中心了。

那我们该如何将正方形的中心点放在画布的中心呢？这就需要我们移动一下图形中心的坐标了。具体的实现方法有两种。

第一种做法是，我们可以让 rect 指令的 x、y 参数，等于画布宽高的一半分别减去矩形自身宽高的一半。这样，我们就把正方形的中心点真正地移动到画布中心了。代码如下所示：

context.rect(0.5 * (canvas.width - rectSize[0]), 0.5 * (canvas.height - rectSize[1]), ...rectSize);

第二种做法是，我们可以先给画布设置一个平移变换（Translate），然后再进行绘制。代码如下所示：

context.translate(-0.5 * rectSize[0], -0.5 * rectSize[1]);

在上面的代码中，我们给画布设置了一个平移，平移距离为矩形宽高的一半，这样它的中心点就是画布的中心了。

既然这两种方法都能将图形绘制到画布的中心，那我们该怎么选择呢？其实，我们可以从这两种方式各自的优缺点入手，下面我就具体来说一说。

第一种方式很简单，它直接改变了我们要绘制的图形顶点的坐标位置，但如果我们绘制的不是矩形，而是很多顶点的多边形，我们就需要在绘图前重新计算出每个顶点的位置，这会非常麻烦。

第二种方式是对 Canvas 画布的整体做一个平移操作，这样我们只需要获取中心点与左上角的偏移，然后对画布设置 translate 变换就可以了，不需要再去改变图形的顶点位置。不过，这样一来我们就改变了画布的状态。如果后续还有其他的图形需要绘制，我们一定要记得把画布状态给恢复回来。好在，这也不会影响到我们已经画好的图形。

那怎么把画布状态恢复回来呢？恢复画布状态的方式有两种，第一种是反向平移。反向平移的原理也很简单，你可以直接看下面的代码。

// 平移
context.translate(-0.5 * rectSize[0], -0.5 * rectSize[1]);

... 执行绘制

// 恢复
context.translate(0.5 * rectSize[0], 0.5 * rectSize[1]);

除了使用反向平移的恢复方式以外，Canvas 上下文还提供了 save 和 restore 方法，可以暂存和恢复画布某个时刻的状态。其中，save 指令不仅可以保存当前的 translate 状态，还可以保存其他的信息，比如，fillStyle 等颜色信息。 而 restore 指令则可以将状态指令恢复成 save 指令前的设置。操作代码如下：

context.save(); // 暂存状态
// 平移
context.translate(-0.5 * rectSize[0], -0.5 * rectSize[1]);

... 执行绘制

context.restore(); // 恢复状态

好了，把一个简单矩形绘制到画布中心的完整方法，我们已经说完了。那我们再来回顾一下利用 Canvas 绘制矩形的过程。我把这个过程总结为了 5 个步骤：

获取 Canvas 对象，通过 getContext(‘2d’) 得到 2D 上下文；

设置绘图状态，比如填充颜色 fillStyle，平移变换 translate 等等；

调用 beginPath 指令开始绘制图形；

调用绘图指令，比如 rect，表示绘制矩形；

调用 fill 指令，将绘制内容真正输出到画布上。

除此之外，Canvas 上下文还提供了更多丰富的 API，可以用来绘制直线、多边形、弧线、圆、椭圆、扇形和贝塞尔曲线等等，这里我们不一一介绍了。在之后的课程中，我们会详细讲解如何利用这些 API 来绘制复杂的几何图形。如果你还想了解更多关于 Canvas 的 API 相关的知识，还可以去阅读MDN 文档。

如何用 Canvas 绘制层次关系图？

知道了 Canvas 的基本用法之后，接下来，我们就可以利用 Canvas 给一组城市数据绘制一个层次关系图了。也就是在一组给出的层次结构数据中，体现出同属于一个省的城市。

在操作之前呢，我们先引入一个概念层次结构数据 （Hierarchy Data），它是可视化领域的专业术语，用来表示能够体现层次结构的信息，例如城市与省与国家。一般来说，层次结构数据用层次关系图表来呈现。

其中，城市数据是一组 JSON 格式的数据，如下所示。

{
  "name":"中国",
  "children":
  [
    {
      "name":"浙江" ,
        "children":
        [
            {"name":"杭州" },
            {"name":"宁波" },
            {"name":"温州" },
            {"name":"绍兴" }
        ]
      },
    {
      "name":"广西" ,
      "children":
      [
        {"name":"桂林"},
        {"name":"南宁"},
        ...
    }
  ]
}

我们要绘制的层次关系图效果如下：

知道了要求之后，我们应该怎么做呢？首先，我们要从数据源获取 JSON 数据。

const dataSource = 'https://s5.ssl.qhres.com/static/b0695e2dd30daa64.json';
(async function () {
  const data = await (await fetch(dataSource)).json();

}();

这份 JSON 数据中只有“城市 > 省份 > 中国”这样的层级数据，我们要将它与绘图指令建立联系。建立联系指的是，我们要把数据的层级、位置和要绘制的圆的半径、位置一一对应起来。

换句话说，我们要把数据转换成图形信息，这个步骤需要数学计算。不过，我们可以直接使用 d3-hierarchyd3-hierarchy这个工具库转换数据。

(async function () {
  const data = await (await fetch(dataSource)).json();

  const regions = d3.hierarchy(data)
    .sum(d => 1)
    .sort((a, b) => b.value - a.value);

  const pack = d3.pack()
    .size([1600, 1600])
    .padding(3);

  const root = pack(regions);
}();

数学计算的内容，我会在数据篇中详细来讲。这里，我们就先不介绍 d3 的具体转换实现了，你只需要知道，我们可以用 d3.hierarchy(data).sum(…).sort(…) 将省份数据按照包含城市的数量，从多到少排序就可以了。

假设，我们要将它们展现在一个画布宽高为 1600 * 1600 的 Canvas 中，那我们可以通过 d3.pack() 将数据映射为一组 1600 宽高范围内的圆形。不过，为了展示得美观一些，在每个相邻的圆之间我们还保留 3 个像素的 padding（按照经验，我们一般是保留 3 个像素 padding 的，但这也要根据实际的设计需求来更改）。

这样，我们就获得了包含几何图形信息的数据对象。此时它的内部结构如下所示：

{
  data: {name: '中国', children: [...]},
  children: [
    {
      data: {name: '江苏', children: [...]},
      value: 7,
      r: 186.00172579386546,
      x: 586.5048250548921,
      y: 748.2441892254667,
    }
    ...
  ],
  value: 69,
  x: 800,
  y: 800,
  r: 800,
}

我们需要的信息是数据中的 x、y、r，这些数值是前面调用 d3.hierarchy 帮我们算出来的。接下来我们只需要用 Canvas 将它们绘制出来就可以了。具体绘制过程比较简单，只需要遍历数据并且根据数据内容绘制圆弧，我也把它总结成了两步。

第一步：我们在当前数据节点绘制一个圆，圆可以使用 arc 指令来绘制。arc 方法的五个参数分别是圆心的 x、y 坐标、半径 r、起始角度和结束角度，前三个参数就是数据中的 x、y 和 r。因为我们要绘制的是整圆，所以后面的两个参数中起始角是 0，结束角是 2π。

第二步，绘制图成后，如果这个数据节点有下一级数据，我们遍历它的下一级数据，然后递归地对这些数据调用绘图过程。如果没有下一级数据，说明当前数据为城市数据，那么我们就直接给出城市的名字，这一步可以通过 fillText 指令来完成。具体的代码如下所示：

const TAU = 2 * Math.PI;

function draw(ctx, node, {fillStyle = 'rgba(0, 0, 0, 0.2)', textColor = 'white'} = {}) {
  const children = node.children;
  const {x, y, r} = node;
  ctx.fillStyle = fillStyle;
  ctx.beginPath();
  ctx.arc(x, y, r, 0, TAU);
  ctx.fill();
  if(children) {
    for(let i = 0; i < children.length; i++) {
      draw(context, children[i]);
    }
  } else {
    const name = node.data.name;
    ctx.fillStyle = textColor;
    ctx.font = '1.5rem Arial';
    ctx.textAlign = 'center';
    ctx.fillText(name, x, y);
  }
}

draw(context, root);

这样，我们就用 Canvas 绘图简单地实现了一个层次关系图，它的代码不多也不复杂，你可以很容易理解，所以我就不做过多的解释啦。

Canvas 有哪些优缺点？

通过上面的例子，相信你已经熟悉了 Canvas 的基础用法。但是，浏览器是一个复杂的图形环境，要想灵活使用 Canvas，我们还需要从宏观层面来知道，它能做什么，不能做什么。

简单来说，就是要了解 Canvas 的优缺点。

首先，Canvas 是一个非常简单易用的图形系统。结合刚才的例子你也能感受到，Canvas 通过一组简单的绘图指令，就能够方便快捷地绘制出各种复杂的几何图形。

另外，Canvas 渲染起来相当高效。即使是绘制大量轮廓非常复杂的几何图形，Canvas 也只需要调用一组简单的绘图指令就能高性能地完成渲染。这个呀，其实和 Canvas 更偏向于渲染层，能够提供底层的图形渲染 API 有关。那在实际实现可视化业务的时候，Canvas 出色的渲染能力正是它的优势所在。

不过 Canvas 也有缺点，因为 Canvas 在 HTML 层面上是一个独立的画布元素，所以所有的绘制内容都是在内部通过绘图指令来完成的，绘制出的图形对于浏览器来说，只是 Canvas 中的一个个像素点，我们很难直接抽取其中的图形对象进行操作。

比如说，在 HTML 或 SVG 中绘制一系列图形的时候，我们可以一一获取这些图形的元素对象，然后给它们绑定用户事件。但同样的操作在 Canvas 中没有可以实现的简单方法（但是我们仍然可以和 Canvas 图形交互，在后续课程中我们会有专门讨论）。下一节课中，我们会详细讲解 SVG 图形系统，到时你就会更加明白它们的差异具体是什么了。

要点总结

好了，Canvas 的使用讲完了，我们来总结一下你要掌握的重点内容。

首先，我们讲了利用 Canvas 绘制几何图形，这个过程很简单，不过依然有 3 点需要我们注意：

在 HTML 中建立画布时，我们要分别设置画布宽高和样式宽高；

在建立坐标系时，我们要注意 canvas 的坐标系和笛卡尔坐标系在 y 轴上是相反的；

如果要把图形绘制在画布中心，我们不能直接让 x、y 的坐标等于画布中心坐标，而是要让图形中心和画布中心的位置重叠。这个操作，我们可以通过计算顶点坐标或者 平移变换来实现。

接着，我们讲了利用 Canvas 展示数据的层级关系。在这个过程中，我们应当先处理数据，将数据内容与绘图指令建立映射关系，然后遍历数据，通过映射关系将代表数据内容的参数传给绘图指令，最后将图形绘制到 Canvas 上。

另外，我们还讲了 Canvas 的优缺点。在实际实现可视化业务的时候，Canvas 的简单易操作和高效的渲染能力是它的优势，但是它的缺点是不能方便地控制它内部的图形元素。

最后，我还有一点想要补充一下。我们今天绘制的图形都是静态的，如果要使用 Canvas 绘制动态的图形也很简单，我们可以通过 clearRect 指令，将之前的图形擦除，再把新的图形绘制上去即可。在后续课程中，我们有专门的章节来介绍动画。

小试牛刀

最后呢，我为你准备了两道课后题，试着动手操作一下吧！

这节课我们介绍了用 Canvas 绘制矩形和圆弧，实际上 Canvas 还有更多的绘图指令来绘制不同类型的几何图形。你可以试着修改一下前面显示正方形的例子，在画布的中心位置显示一个三角形、椭圆或五角星。

Canvas 的缺点是不能方便地控制它内部的图形元素，但这不代表它完全不能控制。你可以尝试给我们前面绘制的层次关系图增加鼠标的交互，让鼠标指针在移动到某个城市所属的圆的时候，这个圆显示不同的颜色（提示：你可以获取鼠标坐标，然后用这个坐标到圆心的距离来判断）。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

用 Canvas 绘制层次关系图的完整代码

推荐阅读

[1] MDN 文档

03 | 声明式图形系统：如何用SVG图形元素绘制可视化图表？

你好，我是月影。今天，我们来讲 SVG。

SVG 的全称是 Scalable Vector Graphics，可缩放矢量图，它是浏览器支持的一种基于 XML 语法的图像格式。

对于前端工程师来说，使用 SVG 的门槛很低。因为描述 SVG 的 XML 语言本身和 HTML 非常接近，都是由标签 + 属性构成的，而且浏览器的 CSS、JavaScript 都能够正常作用于 SVG 元素。这让我们在操作 SVG 时，没什么特别大的难度。甚至，我们可以认为，SVG 就是 HTML 的增强版。

对于可视化来说，SVG 是非常重要的图形系统。它既可以用 JavaScript 操作绘制各种几何图形，还可以作为浏览器支持的一种图片格式，来 独立使用 img 标签加载或者通过 Canvas 绘制。即使我们选择使用 HTML 和 CSS、Canvas2D 或者 WebGL 的方式来实现可视化，但我们依然可以且很有可能会使用到 SVG 图像。所以，关于 SVG 我们得好好学。

那这一节课，我们就来聊聊 SVG 是怎么绘制可视化图表的，以及它的局限性是什么。希望通过今天的讲解，你能掌握 SVG 的基本用法和使用场景。

利用 SVG 绘制几何图形

在第 1 节课我们讲过，SVG 属于声明式绘图系统，它的绘制方式和 Canvas 不同，它不需要用 JavaScript 操作绘图指令，只需要和 HTML 一样，声明一些标签就可以实现绘图了。

那 SVG 究竟是如何绘图的呢？我们先来看一个 SVG 声明的例子。

<svg xmlns="http://www.w3.org/2000/svg" version="1.1">
  <circle cx="100" cy="50" r="40" stroke="black"
  stroke-width="2" fill="orange" />
</svg>

在上面的代码中，svg 元素是 SVG 的根元素，属性 xmlns 是 xml 的名字空间。那第一行代码就表示，svg 元素的 xmlns 属性值是"http://www.w3.org/2000/svg"，浏览器根据这个属性值就能够识别出这是一段 SVG 的内容了。

svg 元素下的 circle 元素表示这是一个绘制在 SVG 图像中的圆形，属性 cx 和 cy 是坐标，表示圆心的位置在图像的 x=100、y=50 处。属性 r 表示半径，r=40 表示圆的半径为 40。

以上，就是这段代码中的主要属性。如果仔细观察你会发现，我们并没有给 100、50、40 指定单位。这是为什么呢？

因为 SVG 坐标系和 Canvas 坐标系完全一样，都是以图像左上角为原点，x 轴向右，y 轴向下的左手坐标系。而且在默认情况下，SVG 坐标与浏览器像素对应，所以 100、50、40 的单位就是 px，也就是像素，不需要特别设置。

说到这，你还记得吗？在 Canvas 中，为了让绘制出来的图形适配不同的显示设备，我们要设置 Canvas 画布坐标。同理，我们也可以通过给 svg 元素设置 viewBox 属性，来改变 SVG 的坐标系。如果设置了 viewBox 属性，那 SVG 内部的绘制就都是相对于 SVG 坐标系的了。

好，现在我们已经知道上面这段代码的含义了。那接下来，我们把它写入 HTML 文档中，就可以在浏览器中绘制出一个带黑框的橙色圆形了。

黑色外框的圆形

现在，我们已经知道了 SVG 的基本用法了。总的来说，它和 HTML 的用法基本一样，你可以参考 HTML 的用法。那接下来，我还是以上一节课实现的层次关系图为例，来看看使用 SVG 该怎么实现。

利用 SVG 绘制层次关系图

我们先来回忆一下，上一节课我们要实现的层次关系图，是在一组给出的层次结构数据中，体现出同属于一个省的城市。数据源和前一节课相同，所以数据的获取部分并没有什么差别。这里我就不列出来了，我们直接来讲绘制的过程。

首先，我们要将获取 Canvas 对象改成获取 SVG 对象，方法是一样的，还是通过选择器来实现。

const svgroot = document.querySelector('svg');

然后，我们同样实现 draw 方法从 root 开始遍历数据对象。 不过，在 draw 方法里，我们不是像上一讲那样，通过 Canvas 的 2D 上下文调用绘图指令来绘图，而是通过创建 SVG 元素，将元素添加到 DOM 文档里，让图形显示出来。具体代码如下：

function draw(parent, node, {fillStyle = 'rgba(0, 0, 0, 0.2)', textColor = 'white'} = {}) {
    const {x, y, r} = node;
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    circle.setAttribute('cx', x);
    circle.setAttribute('cy', y);
    circle.setAttribute('r', r);
    circle.setAttribute('fill', fillStyle);
    parent.appendChild(circle);
    ...
}

draw(svgroot, root);

从上面的代码中你可以看到，我们是使用 document.createElementNS 方法来创建 SVG 元素的。这里你要注意，与使用 document.createElement 方法创建普通的 HTML 元素不同，SVG 元素要使用 document.createElementNS 方法来创建。

其中，第一个参数是名字空间，对应 SVG 名字空间 http://www.w3.org/2000/svg。第二个参数是要创建的元素标签名，因为要绘制圆型，所以我们还是创建 circle 元素。然后我们将 x、y、r 分别赋给 circle 元素的 cx、cy、r 属性，将 fillStyle 赋给 circle 元素的 fill 属性。最后，我们将 circle 元素添加到它的 parent 元素上去。

接着，我们遍历下一级数据。这次，我们创建一个 SVG 的 g 元素，递归地调用 draw 方法。具体代码如下：

if(children) {
    const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    for(let i = 0; i < children.length; i++) {
      draw(group, children[i], {fillStyle, textColor});
    }
    parent.appendChild(group);
  }

SVG 的 g 元素表示一个分组，我们可以用它来对 SVG 元素建立起层级结构。而且，如果我们给 g 元素设置属性，那么它的子元素会继承这些属性。

最后，如果下一级没有数据了，那我们还是需要给它添加文字。在 SVG 中添加文字，只需要创建 text 元素，然后给这个元素设置属性就可以了。操作非常简单，你看我给出的代码就可以理解了。

else {
    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
    text.setAttribute('fill', textColor);
    text.setAttribute('font-family', 'Arial');
    text.setAttribute('font-size', '1.5rem');
    text.setAttribute('text-anchor', 'middle');
    text.setAttribute('x', x);
    text.setAttribute('y', y);
    const name = node.data.name;
    text.textContent = name;
    parent.appendChild(text);
  }

这样，我们就实现了 SVG 版的层次关系图。你看，它是不是看起来和前一节利用 Canvas 绘制的层次关系图没什么差别？纸上得来终觉浅，你可以自己动手实现一下，这样理解得会更深刻。

层次关系图

SVG 和 Canvas 的不同点

那么问题就来了，既然 SVG 和 Canvas 最终的实现效果没什么差别，那在实际使用的时候，我们该如何选择呢？这就需要我们了解 SVG 和 Canvas 在使用上的不同点。知道了这些不同点，我们就能在合适的场景下选择合适的图形系统了。

SVG 和 Canvas 在使用上的不同主要可以分为两点，分别是写法上的不同和用户交互实现上的不同。下面，我们一一来看。

1. 写法上的不同

第 1 讲我们说过，SVG 是以创建图形元素绘图的“声明式”绘图系统，Canvas 是执行绘图指令绘图的“指令式”绘图系统。那它们在写法上具体有哪些不同呢，我们以层次关系图的绘制过程为例来对比一下。

在绘制层次关系图的过程中，SVG 首先通过创建标签来表示图形元素，circle 表示圆，g 表示分组，text 表示文字。接着，SVG 通过元素的 setAttribute 给图形元素赋属性值，这个和操作 HTML 元素是一样的。

而 Canvas 先是通过上下文执行绘图指令来绘制图形，画圆是调用 context.arc 指令，然后再调用 context.fill 绘制，画文字是调用 context.fillText 指令。另外，Canvas 还通过上下文设置状态属性，context.fillStyle 设置填充颜色，conext.font 设置元素的字体。我们设置的这些状态，在绘图指令执行时才会生效。

从写法上来看，因为 SVG 的声明式类似于 HTML 书写方式，本身对前端工程师会更加友好。但是，SVG 图形需要由浏览器负责渲染和管理，将元素节点维护在 DOM 树中。这样做的缺点是，在一些动态的场景中，也就是需要频繁地增加、删除图形元素的场景中，SVG 与一般的 HTML 元素一样会带来 DOM 操作的开销，所以 SVG 的渲染性能相对比较低。

那除了写法不同以外，SVG 和 Canvas 还有其他区别吗？当然是有的，不过我要先卖一个关子，我们讲完一个例子再来说。

2. 用户交互实现上的不同

我们尝试给这个 SVG 版本的层次关系图添加一个功能，也就是当鼠标移动到某个区域时，这个区域会高亮，并且显示出对应的省 - 市信息。

因为 SVG 的一个图形对应一个元素，所以我们可以像处理 DOM 元素一样，很容易地给 SVG 图形元素添加对应的鼠标事件。具体怎么做呢？我们一起来看。

首先，我们要给 SVG 的根元素添加 mousemove 事件，添加代码的操作很简单，你可以直接看代码。

 let activeTarget = null;
  svgroot.addEventListener('mousemove', (evt) => {
    let target = evt.target;
    if(target.nodeName === 'text') target = target.parentNode;
    if(activeTarget !== target) {
      if(activeTarget) activeTarget.setAttribute('fill', 'rgba(0, 0, 0, 0.2)');
    }
    target.setAttribute('fill', 'rgba(0, 128, 0, 0.1)');
    activeTarget = target;
  });

就像是我们熟悉的 HTML 用法一样，我们通过事件冒泡可以处理每个圆上的鼠标事件。然后，我们把当前鼠标所在的圆的颜色填充成’rgba(0, 128, 0, 0.1)’，这个颜色是带透明的浅绿色。最终的效果就是当我们的鼠标移动到圆圈范围内的时候，当前鼠标所在的圆圈变为浅绿色。你也可以尝试设置其他的值，看看不同的实现效果。

接着，我们要实现显示对应的省 - 市信息。在这里，我们需要修改一下 draw 方法。具体的修改过程，可以分为两步。

第一步，是把省、市信息通过扩展属性 data-name 设置到 svg 的 circle 元素上，这样我们就可以在移动鼠标的时候，通过读取鼠标所在元素的属性，拿到我们想要展示的省、市信息了。具体代码如下：

  function draw(parent, node, {fillStyle = 'rgba(0, 0, 0, 0.2)', textColor = 'white'} = {}) {
    ...
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    ...
    circle.setAttribute('data-name', node.data.name);
    ...

    if(children) {
     const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
      ...
      group.setAttribute('data-name', node.data.name);
      ...
    } else {
      ...
    }
  }

第二步，我们要实现一个 getTitle 方法，从当前鼠标事件的 target 往上找 parent 元素，拿到“省 - 市”信息，把它赋给 titleEl 元素。这个 titleEl 元素是我们添加到网页上的一个 h1 元素，用来显示省、市信息。

 const titleEl = document.getElementById('title');

  function getTitle(target) {
    const name = target.getAttribute('data-name');
    if(target.parentNode && target.parentNode.nodeName === 'g') {
      const parentName = target.parentNode.getAttribute('data-name');
      return `${parentName}-${name}`;
    }
    return name;
  }

最后，我们就可以在 mousemove 事件中更新 titleEl 的文本内容了。

  svgroot.addEventListener('mousemove', (evt) => {
    ...
    titleEl.textContent = getTitle(target);
    ...
  });

这样，我们就实现了给层次关系图增加鼠标控制的功能，最终的效果如下图所示，完整的代码我放在GitHub 仓库了，你可以自己去查看。

其实，我们上面讲的鼠标控制功能就是一个简单的用户交互功能。总结来说，利用 SVG 的一个图形对应一个 svg 元素的机制，我们就可以像操作普通的 HTML 元素那样，给 svg 元素添加事件实现用户交互了。所以，SVG 有一个非常大的优点，那就是可以让图形的用户交互非常简单。

和 SVG 相比，利用 Canvas 对图形元素进行用户交互就没有那么容易了。不过，对于圆形的层次关系图来说，在 Canvas 图形上定位鼠标处于哪个圆中并不难，我们只需要计算一下鼠标到每个圆的圆心距离，如果这个距离小于圆的半径，我们就可以确定鼠标在某个圆内部了。这实际上就是上一节课我们留下的思考题，相信现在你应该可以做出来了。

但是试想一下，如果我们要绘制的图形不是圆、矩形这样的规则图形，而是一个复杂得多的多边形，我们又该怎样确定鼠标在哪个图形元素的内部呢？这对于 Canvas 来说，就是一个比较复杂的问题了。不过这也不是不能解决的，在后续的课程中，我们就会讨论如何用数学计算的办法来解决这个问题。

绘制大量几何图形时 SVG 的性能问题

虽然使用 SVG 绘图能够很方便地实现用户交互，但是有得必有失，SVG 这个设计给用户交互带来便利性的同时，也带来了局限性。为什么这么说呢？因为它和 DOM 元素一样，以节点的形式呈现在 HTML 文本内容中，依靠浏览器的 DOM 树渲染。如果我们要绘制的图形非常复杂，这些元素节点的数量就会非常多。而节点数量多，就会大大增加 DOM 树渲染和重绘所需要的时间。

就比如说，在绘制如上的层次关系图时，我们只需要绘制数十个节点。但是如果是更复杂的应用，比如我们要绘制数百上千甚至上万个节点，这个时候，DOM 树渲染就会成为性能瓶颈。事实上，在一般情况下，当 SVG 节点超过一千个的时候，你就能很明显感觉到性能问题了。

幸运的是，对于 SVG 的性能问题，我们也是有解决方案的。比如说，我们可以使用虚拟 DOM 方案来尽可能地减少重绘，这样就可以优化 SVG 的渲染。但是这些方案只能解决一部分问题，当节点数太多时，这些方案也无能为力。这个时候，我们还是得依靠 Canvas 和 WebGL 来绘图，才能彻底解决问题。

那在上万个节点的可视化应用场景中，SVG 就真的一无是处了吗？当然不是。SVG 除了嵌入 HTML 文档的用法，还可以直接作为一种图像格式使用。所以，即使是在用 Canvas 和 WebGL 渲染的应用场景中，我们也依然可能会用到 SVG，将它作为一些局部的图形使用，这也会给我们的应用实现带来方便。在后续的课程中，我们会遇到这样的案例。

要点总结

这一节课我们学习了 SVG 的基本用法、优点和局限性。

我们知道，SVG 作为一种浏览器支持的图像格式，既可以作为 HTML 内嵌元素使用，也可以作为图像通过 img 元素加载，或者绘制到 Canvas 内。

而用 SVG 绘制可视化图形与用 Canvas 绘制有明显区别，SVG 通过创建标签来表示图形元素，然后将图形元素添加到 DOM 树中，交给 DOM 完成渲染。

使用 DOM 树渲染可以让图形元素的用户交互实现起来非常简单，因为我们可以直接对图形元素注册事件。但是这也带来问题，如果图形复杂，那么 SVG 的图形元素会非常多，这会导致 DOM 树渲染成为性能瓶颈。

小试牛刀

DOM 操作 SVG 元素和操作普通的 HTML 元素几乎没有差别，所以 CSS 也同样可以作用于 SVG 元素。那你可以尝试使用 CSS，来设置这节课我们实现的层级关系图里，circle 的背景色和文字属性。接着你也可以进一步想一想，这样做有什么好处？

因为 SVG 可以作为一种图像格式使用，所以我们可以将生成的 SVG 作为图像，然后绘制到 Canvas 上。那如果我们先用 SVG 生成层级关系图，再用 Canvas 来完成绘制的话，和我们单独使用它们来绘图有什么不同？为什么？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

用 SVG 绘制层次关系图和给层次关系图增加鼠标控制的完整代码

04 | GPU与渲染管线：如何用WebGL绘制最简单的几何图形？（上）

你好，我是月影。今天，我们要讲 WebGL。今天的内容比较多，也比较重要，所以我们会分上、下两节课来讲。

WebGL 是最后一个和可视化有关的图形系统，也是最难学的一个。为啥说它难学呢？我觉得这主要有两个原因。第一，WebGL 这种技术本身就是用来解决最复杂的视觉呈现的。比如说，大批量绘制复杂图形和 3D 模型，这类比较有难度的问题就适合用 WebGL 来解决。第二，WebGL 相对于其他图形系统来说，是一个更“开放”的系统。

我说的“开放”是针对于底层机制而言的。因为，不管是 HTML/CSS、SVG 还是 Canvas，都主要是使用其 API 来绘制图形的，所以我们不必关心它们具体的底层机制。也就是说，我们只要理解创建 SVG 元素的绘图声明，学会执行 Canvas 对应的绘图指令，能够将图形输出，这就够了。但是，要使用 WebGL 绘图，我们必须要深入细节里。换句话说就是，我们必须要和内存、GPU 打交道，真正控制图形输出的每一个细节。

所以，想要学好 WebGL，我们必须先理解一些基本概念和原理。那今天这一节课，我会从图形系统的绘图原理开始讲起，主要来讲 WebGL 最基础的概念，包括 GPU、渲染管线、着色器。然后，我会带你用 WebGL 绘制一个简单的几何图形。希望通过这个可视化的例子，能够帮助你理解 WebGL 绘制图形的基本原理，打好绘图的基础。

图形系统是如何绘图的？

首先，我们来说说计算机图形系统的主要组成部分，以及它们在绘图过程中的作用。知道了这些，我们就能很容易理解计算机图形系统绘图的基本原理了。

一个通用计算机图形系统主要包括 6 个部分，分别是输入设备、中央处理单元、图形处理单元、存储器、帧缓存和输出设备。虽然我下面给出了绘图过程的示意图，不过这些设备在可视化中的作用，我要再跟你多啰嗦几句。

光栅（Raster）：几乎所有的现代图形系统都是基于光栅来绘制图形的，光栅就是指构成图像的像素阵列。

像素（Pixel）：一个像素对应图像上的一个点，它通常保存图像上的某个具体位置的颜色等信息。

帧缓存（Frame Buffer）：在绘图过程中，像素信息被存放于帧缓存中，帧缓存是一块内存地址。

CPU（Central Processing Unit）：中央处理单元，负责逻辑计算。

GPU（Graphics Processing Unit）：图形处理单元，负责图形计算。

知道了这些概念，我带你来看一个典型的绘图过程，帮你来明晰一下这些概念的实际用途。

首先，数据经过 CPU 处理，成为具有特定结构的几何信息。然后，这些信息会被送到 GPU 中进行处理。在 GPU 中要经过两个步骤生成光栅信息。这些光栅信息会输出到帧缓存中，最后渲染到屏幕上。

图形数据经过GPU处理最终输出到屏幕上

这个绘图过程是现代计算机中任意一种图形系统处理图形的通用过程。它主要做了两件事，一是对给定的数据结合绘图的场景要素（例如相机、光源、遮挡物体等等）进行计算，最终将图形变为屏幕空间的 2D 坐标。二是为屏幕空间的每个像素点进行着色，把最终完成的图形输出到显示设备上。这整个过程是一步一步进行的，前一步的输出就是后一步的输入，所以我们也把这个过程叫做渲染管线（RenderPipelines）。

在这个过程中，CPU 与 GPU 是最核心的两个处理单元，它们参与了计算的过程。CPU 我相信你已经比较熟悉了，但是 GPU 又是什么呢？别着急，听我慢慢和你讲。

GPU 是什么？

CPU 和 GPU 都属于处理单元，但是结构不同。形象点来说，CPU 就像个大的工业管道，等待处理的任务就像是依次通过这个管道的货物。一条 CPU 流水线串行处理这些任务的速度，取决于 CPU（管道）的处理能力。

实际上，一个计算机系统会有很多条 CPU 流水线，而且任何一个任务都可以随机地通过任意一个流水线，这样计算机就能够并行处理多个任务了。这样的一条流水线就是我们常说的线程（Thread）。

CPU

这样的结构用来处理大型任务是足够的，但是要处理图像应用就不太合适了。这是因为，处理图像应用，实际上就是在处理计算图片上的每一个像素点的颜色和其他信息。每处理一个像素点就相当于完成了一个简单的任务，而一个图片应用又是由成千上万个像素点组成的，所以，我们需要在同一时间处理成千上万个小任务。

要处理这么多的小任务，比起使用若干个强大的 CPU，使用更小、更多的处理单元，是一种更好的处理方式。而 GPU 就是这样的处理单元。

GPU

GPU 是由大量的小型处理单元构成的，它可能远远没有 CPU 那么强大，但胜在数量众多，可以保证每个单元处理一个简单的任务。即使我们要处理一张 800 * 600 大小的图片，GPU 也可以保证这 48 万个像素点分别对应一个小单元，这样我们就可以同时对每个像素点进行计算了。

那 GPU 究竟是怎么完成像素点计算的呢？这就必须要和 WebGL 的绘图过程结合起来说了。

如何用 WebGL 绘制三角形？

浏览器提供的 WebGL API 是 OpenGL ES 的 JavaScript 绑定版本，它赋予了开发者操作 GPU 的能力。这一特点也让 WebGL 的绘图方式和其他图形系统的“开箱即用”（直接调用绘图指令或者创建图形元素就可以完成绘图）的绘图方式完全不同，甚至要复杂得多。我们可以总结为以下 5 个步骤：

创建 WebGL 上下文

创建 WebGL 程序（WebGL Program）

将数据存入缓冲区

将缓冲区数据读取到 GPU

GPU 执行 WebGL 程序，输出结果

别看这些步骤看起来很简单，但其中会涉及许多你没听过的新概念、方法以及各种参数。不过，这也不用担心，我们今天的重点还是放在理解 WebGL 的基本用法和绘制原理上，对于新的方法具体怎么用，参数如何设置，这些我们都会在后面的课程中详细来讲。

接下来，我们就用一个绘制三角形的例子，来讲一下这些步骤的具体操作过程。

步骤一：创建 WebGL 上下文

创建 WebGL 上下文这一步和 Canvas2D 的使用几乎一样，我们只要调用 canvas 元素的 getContext 即可，区别是将参数从’2d’换成’webgl’。

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

不过，有了 WebGL 上下文对象之后，我们并不能像使用 Canvas2D 的上下文那样，调用几个绘图指令就把图形画出来，还需要做很多工作。别着急，让我们一步一步来。

步骤二：创建 WebGL 程序

接下来，我们要创建一个 WebGL 程序。你可能会觉得奇怪，我们不是正在写一个绘制三角形的程序吗？为什么这里又要创建一个 WebGL 程序呢？实际上，这里的 WebGL 程序是一个 WebGLProgram 对象，它是给 GPU 最终运行着色器的程序，而不是我们正在写的三角形的 JavaScript 程序。好了，解决了这个疑问，我们就正式开始创建一个 WebGL 程序吧！

首先，要创建这个 WebGL 程序，我们需要编写两个着色器（Shader）。着色器是用 GLSL 这种编程语言编写的代码片段，这里我们先不用过多纠结于 GLSL 语言，在后续的课程中我们会详细讲解。那在这里，我们只需要理解绘制三角形的这两个着色器的作用就可以了。

const vertex = `
  attribute vec2 position;

  void main() {
    gl_PointSize = 1.0;
    gl_Position = vec4(position, 1.0, 1.0);
  }
`;

const fragment = `
  precision mediump float;

  void main()
  {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
`;

那我们为什么要创建两个着色器呢？这就需要我们先来理解顶点和图元这两个基本概念了。在绘图的时候，WebGL 是以顶点和图元来描述图形几何信息的。顶点就是几何图形的顶点，比如，三角形有三个顶点，四边形有四个顶点。图元是 WebGL 可直接处理的图形单元，由 WebGL 的绘图模式决定，有点、线、三角形等等。

所以，顶点和图元是绘图过程中必不可少的。因此，WebGL 绘制一个图形的过程，一般需要用到两段着色器，一段叫顶点着色器（Vertex Shader）负责处理图形的顶点信息，另一段叫片元着色器（Fragment Shader）负责处理图形的像素信息。

更具体点来说，我们可以把顶点着色器理解为处理顶点的 GPU 程序代码。它可以改变顶点的信息（如顶点的坐标、法线方向、材质等等），从而改变我们绘制出来的图形的形状或者大小等等。

顶点处理完成之后，WebGL 就会根据顶点和绘图模式指定的图元，计算出需要着色的像素点，然后对它们执行片元着色器程序。简单来说，就是对指定图元中的像素点着色。

WebGL 从顶点着色器和图元提取像素点给片元着色器执行代码的过程，就是我们前面说的生成光栅信息的过程，我们也叫它光栅化过程。所以，片元着色器的作用，就是处理光栅化后的像素信息。

这么说可能比较抽象，我 来举个例子。我们可以将图元设为线段，那么片元着色器就会处理顶点之间的线段上的像素点信息，这样画出来的图形就是空心的。而如果我们把图元设为三角形，那么片元着色器就会处理三角形内部的所有像素点，这样画出来的图形就是实心的。

这里你要注意一点，因为图元是 WebGL 可以直接处理的图形单元，所以其他非图元的图形最终必须要转换为图元才可以被 WebGL 处理。举个例子，如果我们要绘制实心的四边形，我们就需要将四边形拆分成两个三角形，再交给 WebGL 分别绘制出来。

好了，那让我们回到片元着色器对像素点着色的过程。你还要注意，这个过程是并行的。也就是说，无论有多少个像素点，片元着色器都可以同时处理。这也是片元着色器一大特点。

以上就是片元着色器的作用和使用特点了，关于顶点着色器的作用我们一会儿再说。说了这么多，你可别忘了，创建着色器的目的是为了创建 WebGL 程序，那我们应该如何用顶点着色器和片元着色器代码，来创建 WebGL 程序呢？

这个我们放到下半节课来讲。那上半节课讲完了，你可以先休息一会，再接着来看下半节。

04 | GPU与渲染管线：如何用WebGL绘制最简单的几何图形？（下）

你好，我是月影。欢迎回来，刚才我们说完了顶点着色器和片元着色器，接着我们要来利用它们实现 WebGL 程序了。

首先，因为在 JavaScript 中，顶点着色器和片元着色器只是一段代码片段，所以我们要将它们分别创建成 shader 对象。代码如下所示：

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);

接着，我们创建 WebGLProgram 对象，并将这两个 shader 关联到这个 WebGL 程序上。WebGLProgram 对象的创建过程主要是添加 vertexShader 和 fragmentShader，然后将这个 WebGLProgram 对象链接到 WebGL 上下文对象上。代码如下：

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);

最后，我们要通过 useProgram 选择启用这个 WebGLPrgame 对象。这样，当我们绘制图形时，GPU 就会执行我们通过 WebGLProgram 设定的 两个 shader 程序了。

gl.useProgram(program);

好了，现在我们已经创建并完成 WebGL 程序的配置。接下来， 我们只要将三角形的数据存入缓冲区，也就能将这些数据送入 GPU 了。那实现这一步之前呢，我们先来认识一下 WebGL 的坐标系。

步骤三：将数据存入缓冲区

我们要知道 WebGL 的坐标系是一个三维空间坐标系，坐标原点是（0,0,0）。其中，x 轴朝右，y 轴朝上，z 轴朝外。这是一个右手坐标系。

假设，我们要在这个坐标系上显示一个顶点坐标分别是（-1, -1）、（1, -1）、（0, 1）的三角形，如下图所示。因为这个三角形是二维的，所以我们可以直接忽略 z 轴。下面，我们来一起绘图。

首先，我们要定义这个三角形的三个顶点。WebGL 使用的数据需要用类型数组定义，默认格式是 Float32Array。Float32Array 是 JavaScript 的一种类型化数组（TypedArray），JavaScript 通常用类型化数组来处理二进制缓冲区。

因为平时我们在 Web 前端开发中，使用到类型化数组的机会并不多，你可能还不大熟悉，不过没关系，类型化数组的使用并不复杂，定义三角形顶点的过程，你直接看我下面给出的代码就能理解。不过，如果你之前完全没有接触过它，我还是建议你阅读MDN 文档，去详细了解一下类型化数组的使用方法。

const points = new Float32Array([
  -1, -1,
  0, 1,
  1, -1,
]);

接着，我们要将定义好的数据写入 WebGL 的缓冲区。这个过程我们可以简单总结为三步，分别是创建一个缓存对象，将它绑定为当前操作对象，再把当前的数据写入缓存对象。这三个步骤主要是利用 createBuffer、bindBuffer、bufferData 方法来实现的，过程很简单你可以看一下我下面给出的实现代码。

const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

步骤四：将缓冲区数据读取到 GPU

现在我们已经把数据写入缓存了，但是我们的 shader 现在还不能读取这个数据，还需要把数据绑定给顶点着色器中的 position 变量。

还记得我们的顶点着色器是什么样的吗？它是按如下的形式定义的：

attribute vec2 position;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(position, 1.0, 1.0);
}

在 GLSL 中，attribute 表示声明变量，vec2 是变量的类型，它表示一个二维向量，position 是变量名。接下来我们将 buffer 的数据绑定给顶点着色器的 position 变量。

const vPosition = gl.getAttribLocation(program, 'position');获取顶点着色器中的position变量的地址
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);给变量设置长度和类型
gl.enableVertexAttribArray(vPosition);激活这个变量

经过这样的处理，在顶点着色器中，我们定义的 points 类型数组中对应的值，就能通过变量 position 读到了。

步骤五：执行着色器程序完成绘制

现在，我们把数据传入缓冲区以后，GPU 也可以读取绑定的数据到着色器变量了。接下来，我们只需要调用绘图指令，就可以执行着色器程序来完成绘制了。

我们先调用 gl.clear 将当前画布的内容清除，然后调用 gl.drawArrays 传入绘制模式。这里我们选择 gl.TRIANGLES 表示以三角形为图元绘制，再传入绘制的顶点偏移量和顶点数量，WebGL 就会将对应的 buffer 数组传给顶点着色器，并且开始绘制。代码如下：

gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

这样，我们就在 Canvas 画布上画出了一个红色三角形。

为什么是红色三角形呢？因为我们在片元着色器中定义了像素点的颜色，代码如下：

precision mediump float;

void main()
{
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

在片元着色器里，我们可以通过设置 gl_FragColor 的值来定义和改变图形的颜色。gl_FragColor 是 WebGL 片元着色器的内置变量，表示当前像素点颜色，它是一个用 RGBA 色值表示的四维向量数据。在上面的代码中，因为我们写入 vec4(1.0, 0.0, 0.0, 1.0) 对应的是红色，所以三角形是红色的。如果我们把这个值改成 vec4(0.0, 0.0, 1.0, 1.0)，那三角形就是蓝色。

我为什么会强调颜色这个事儿呢？你会发现，刚才我们只更改了一个值，就把整个图片的所有像素颜色都改变了。所以，我们必须要认识到一点，WebGL 可以并行地对整个三角形的所有像素点同时运行片元着色器。并行处理是 WebGL 程序非常重要的概念，所以我就多强调一下。

我们要记住，不论这个三角形是大还是小，有几十个像素点还是上百万个像素点，GPU 都是同时处理每个像素点的。也就是说，图形中有多少个像素点，着色器程序在 GPU 中就会被同时执行多少次。

到这里，WebGL 绘制三角形的过程我们就讲完了。借助这个过程，我们加深了对顶点着色器和片元着色器在使用上的理解。不过，因为后面我们会更多地讲解片元着色器的绘图方法，那今天，我们正好可以借着这个机会，多讲讲顶点着色器的应用，我希望你也能掌握好它。

顶点着色器的作用

顶点着色器大体上可以总结为两个作用：一是通过 gl_Position 设置顶点，二是通过定义 varying 变量，向片元着色器传递数据。这么说还是有点抽象，我们还是通过三角形的例子来具体理解一下。

1. 通过 gl_Position 设置顶点

假如，我想把三角形的周长缩小为原始大小的一半，有两种处理方式法：一种是修改 points 数组的值，另一种做法是直接对顶点着色器数据进行处理。第一种做法很简单，我就不讲了，如果不懂你可以在留言区提问。我们来详细说说第二种做法。

我们不需要修改 points 数据，只需要在顶点着色器中，将 gl_Position = vec4(position, 1.0, 1.0); 修改为 gl_Position = vec4(position * 0.5, 1.0, 1.0);，代码如下所示。

attribute vec2 position;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(position * 0.5, 1.0, 1.0);
}

这样，三角形的周长就缩小为原来的一半了。在这个过程中，我们不需要遍历三角形的每一个顶点，只需要是利用 GPU 的并行特性，在顶点着色器中同时计算所有的顶点就可以了。在后续课程中，我们还会遇到更加复杂的例子，但在那之前，你一定要理解并牢记 WebGL 可以并行计算这一特点。

2. 向片元着色器传递数据

除了计算顶点之外，顶点着色器还可以将数据通过 varying 变量传给片元着色器。然后，这些值会根据片元着色器的像素坐标与顶点像素坐标的相对位置做线性插值。这是什么意思呢？其实这很难用文字描述，我们还是来看一段代码：

attribute vec2 position;
varying vec3 color;

void main() {
  gl_PointSize = 1.0;
  color = vec3(0.5 + position * 0.5, 0.0);
  gl_Position = vec4(position * 0.5, 1.0, 1.0);
}

在这段代码中，我们修改了顶点着色器，定义了一个 color 变量，它是一个三维的向量。我们通过数学技巧将顶点的值映射为一个 RGB 颜色值（关于顶点映射 RGB 颜色值的方法，在后续的课程中会有详细介绍），映射公式是 vec3(0.5 + position * 0.5, 0.0)。

这样一来，顶点[-1,-1]被映射为[0,0,0]也就是黑色，顶点[0,1]被映射为[0.5, 1, 0]也就是浅绿色，顶点[1,-1]被映射为[1,0,0]也就是红色。这样一来，三个顶点就会有三个不同的颜色值。

然后我们将 color 通过 varying 变量传给片元着色器。片元着色器中的代码如下：

precision mediump float;
varying vec3 color;

void main()
{
  gl_FragColor = vec4(color, 1.0);
}

我们将 gl_FragColor 的 rgb 值设为变量 color 的值，这样我们就能得到下面这个三角形：

我们可以看到，这个三角形是一个颜色均匀（线性）渐变的三角形，它的三个顶点的色值就是我们通过顶点着色器来设置的。而且你会发现，中间像素点的颜色是均匀过渡的。这就是因为 WebGL 在执行片元着色器程序的时候，顶点着色器传给片元着色器的变量，会根据片元着色器的像素坐标对变量进行线性插值。利用线性插值可以让像素点的颜色均匀渐变这一特点，我们就能绘制出颜色更丰富的图形了。

好了，到这里，我们就在 Canvas 画布上用 WebGL 绘制出了一个三角形。绘制三角形的过程，就像我们初学编程时去写出一个 Hello World 程序一样，按道理来说，应该非常简单才对。但事实上，用 WebGL 完成这个程序，我们一共用了好几十行代码。而如果我们用 Canvas2D 或者 SVG 实现类似的功能，只需要几行代码就可以了。

那我们为什么非要这么做呢？而且我们费了很大的劲，就只绘制出了一个最简单的三角形，这似乎离我们用 WebGL 实现复杂的可视化效果还非常遥远。我想告诉你的是，别失落，想要利用 WebGL 绘制更有趣、更复杂的图形，我们就必须要学会绘制三角形这个图元。还记得我们前面说过的，要在 WebGL 中绘制非图元的其他图形时，我们必须要把它们划分成三角形才行。学习了后面的课程之后，你就会对这一点有更深刻的理解了。

而且，用 WebGL 可以实现的视觉效果，远远超越其他三个图形系统。如果用驾驶技术来比喻的话，使用 SVG 和 Canvas2D 时，就像我们在开一辆自动挡的汽车，那么使用 WebGL 的时候，就像是在开战斗机！所以，千万别着急，随着对 WebGL 的不断深入理解，我们就能用它来实现更多有趣的实例了。

要点总结

在这一节课，我们讲了 WebGL 的绘图过程以及顶点着色器和片元着色器的作用。

WebGL 图形系统与用其他图形系统不同，它的 API 非常底层，使用起来比较复杂。想要学好 WebGL，我们必须要从基础概念和原理学起。

一般来说，在 WebGL 中要完成图形的绘制，需要创建 WebGL 程序，然后将图形的几何数据存入数据缓冲区，在绘制过程中让 WebGL 从缓冲区读取数据，并且执行着色器程序。

WebGL 的着色器程序有两个。一个是顶点着色器，负责处理图形的顶点数据。另一个是片元着色器，负责处理光栅化后的像素信息。此外，我们还要牢记，WebGL 程序有一个非常重要的特点就是能够并行处理，无论图形中有多少个像素点，都可以通过着色器程序在 GPU 中被同时执行。

WebGL 完整的绘图过程实在比较复杂，为了帮助你理解，我总结一个流程图，供你参考。

WebGL绘图流程

那到这里，可视化的四个图形系统我们就介绍完了。但是，好戏才刚刚开始哦，在后续的文章中我们会围绕着这四个图形系统，尤其是 Canvas2D 和 WebGL 逐渐深入，来实现更多有趣的图形。

小试牛刀

WebGL 通过顶点和图元来绘制图形，我们在上面的例子中，调用 gl.TRIANGLES 绘制出了实心的三角形。如果要绘制空心三角形，我们又应该怎么做呢？有哪些图元类型可以帮助我们完成这个绘制？

三角形是最简单的几何图形，如果我们要绘制其他的几何图形，我们可以通过用多个三角形拼接来实现。试着用 WebGL 绘制正四边形、正五边形和正六角星吧！

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这一节课分享给你的朋友，我们下节课再见！

源码

[1] 示例代码

推荐阅读

[1] 类型化数组 MDN 文档

[2] WebGL MDN 文档

05 | 如何用向量和坐标系描述点和线段？

你好，我是月影。

为什么你做了很多可视化项目，解决了一个、两个、三个甚至多个不同类型的图表展现之后，还是不能系统地提升自己的能力，在下次面对新的项目时依然会有各种难以克服的困难？这是因为你陷入了细节里。

什么是细节？简单来说，细节就是各种纯粹的图形学问题。在可视化项目里，我们需要描述很多的图形，而描述图形的顶点、边、线、面、体和其他各种信息有很多不同的方法。并且，如果我们使用不同的绘图系统，每个绘图系统又可能有独特的方式或者特定的 API，去解决某个或某类具体的问题。

正因为有了太多可以选择的工具，我们也就很难找到最恰当的那一个。而且如果我们手中只有解决具体问题的工具，没有统一的方法论，那我们也无法一劳永逸地解决问题的根本。

因此，我们要建立一套与各个图形系统无关联的、简单的基于向量和矩阵运算的数学体系，用它来描述所有的几何图形信息。这就是我在数学篇想要和你讨论的主要问题，也就是如何建立一套描述几何图形信息的数学体系，以及如何用这个体系来解决我们的可视化图形呈现的问题。

那这一节课，我们先学习用坐标系与向量来描述基本图形的方法，从如何定义和变换图形的直角坐标系，以及如何运用向量表示点和线段这两方面讲起。

坐标系与坐标映射

首先，我们来看看浏览器的四个图形系统通用的坐标系分别是什么样的。

HTML 采用的是窗口坐标系，以参考对象（参考对象通常是最接近图形元素的 position 非 static 的元素）的元素盒子左上角为坐标原点，x 轴向右，y 轴向下，坐标值对应像素值。

SVG 采用的是视区盒子（viewBox）坐标系。这个坐标系在默认情况下，是以 svg 根元素左上角为坐标原点，x 轴向右，y 轴向下，svg 根元素右下角坐标为它的像素宽高值。如果我们设置了 viewBox 属性，那么 svg 根元素左上角为 viewBox 的前两个值，右下角为 viewBox 的后两个值。

Canvas 采用的坐标系我们比较熟悉了，它默认以画布左上角为坐标原点，右下角坐标值为 Canvas 的画布宽高值。

WebGL 的坐标系比较特殊，是一个三维坐标系。它默认以画布正中间为坐标原点，x 轴朝右，y 轴朝上，z 轴朝外，x 轴、y 轴在画布中范围是 -1 到 1。

尽管这四个坐标系在原点位置、坐标轴方向、坐标范围上有所区别，但都是直角坐标系，所以它们都满足直角坐标系的特性：不管原点和轴的方向怎么变，用同样的方法绘制几何图形，它们的形状和相对位置都不变。

为了方便处理图形，我们经常需要对坐标系进行转换。转换坐标系可以说是一个非常基础且重要的操作了。正因为这四个坐标系都是直角坐标系，所以它们可以很方便地相互转化。其中，HTML、SVG 和 Canvas 都提供了 transform 的 API 能够帮助我们很方便地转换坐标系。而 WebGL 本身不提供 tranform 的 API，但我们可以在 shader 里做矩阵运算来实现坐标转换，WebGL 的问题我们在后续课程会有专门讨论，今天我们先来说说其他三种。那接下来我们就以 Canvas 为例，来看看用 transform API 怎样进行坐标转换。

如何用 Canvas 实现坐标系转换？

假设，我们要在宽 512 * 高 256 的一个 Canvas 画布上实现如下的视觉效果。其中，山的高度是 100，底边 200，两座山的中心位置到中线的距离都是 80，太阳的圆心高度是 150。

当然，在不转换坐标系的情况下，我们也可以把图形绘制出来，但是要经过顶点换算，下面我们就来说一说这个过程。

首先，因为 Canvas 坐标系默认的原点是左上角，底边的 y 坐标是 256，而山的高度是 100，所以山顶点的 y 坐标是 256 - 100 = 156。而因为太阳的高度是 150，所以太阳圆心的 y 坐标是 256 - 150 = 106。

然后，因为 x 轴中点的坐标是 512 / 2 = 256，所以两座山顶点的 x 坐标分别是 256 - 80 和 256 + 80，也就是 176 和 336。又因为山是等腰三角形，它的底边是 200，所以两座山底边的 x 坐标计算出来，分别是 76、276、236、436（176 - 100 =76、176 + 100=276、336 - 100=236、 336 + 100=436）。

计算出这些坐标之后，我们很容易就可以将这个图画出来了。不过，为了增加一些趣味性，我们用一个Rough.js的库，绘制一个手绘风格的图像（Rough.js 库的 API 和 Canvas 差不多，绘制出来的图形比较有趣）。绘制的代码如下所示：

const rc = rough.canvas(document.querySelector('canvas'));
const hillOpts = {roughness: 2.8, strokeWidth: 2, fill: 'blue'};
rc.path('M76 256L176 156L276 256', hillOpts);
rc.path('M236 256L336 156L436 256', hillOpts);
rc.circle(256, 106, 105, {
  stroke: 'red',
  strokeWidth: 4,
  fill: 'rgba(255, 255, 0, 0.4)',
  fillStyle: 'solid',
});

最终，我们绘制出的图形效果如下所示：

到这里，我们通过简单的计算就绘制出了这一组图形。但你也能够想到，如果每次绘制都要花费时间在坐标换算上，这会非常不方便。所以，为了解决这个问题，我们可以采用坐标系变换来代替坐标换算。

这里，我们给 Canvas 的 2D 上下文设置一下 transform 变换。我们经常会用到两个变换：translate 和 scale。

首先，我们通过 translate 变换将 Canvas 画布的坐标原点，从左上角 (0, 0) 点移动至 (256, 256) 位置，即画布的底边上的中点位置。接着，以移动了原点后新的坐标为参照，通过 scale(1, -1) 将 y 轴向下的部分，即 y>0 的部分沿 x 轴翻转 180 度，这样坐标系就变成以画布底边中点为原点，x 轴向右，y 轴向上的坐标系了。

坐标系

执行了这个坐标变换，也就是让坐标系原点在中间之后，我们就可以更方便、直观地计算出几个图形元素的坐标了。

两个山顶的坐标就是 (-80, 100) 和 (80, 100)，山脚的坐标就是 (-180, 0)、(20, 0)、(-20, 0)、(180, 0)，太阳的中心点的坐标就是 (0, 150)。那么更改后的代码如下所示。

const rc = rough.canvas(document.querySelector('canvas'));
const ctx = rc.ctx;
ctx.translate(256, 256);
ctx.scale(1, -1);

const hillOpts = {roughness: 2.8, strokeWidth: 2, fill: 'blue'};

rc.path('M-180 0L-80 100L20 0', hillOpts);
rc.path('M-20 0L80 100L180 0', hillOpts);

rc.circle(0, 150, 105, {
  stroke: 'red',
  strokeWidth: 4,
  fill: 'rgba(255,255, 0, 0.4)',
  fillStyle: 'solid',
});

好了，现在我们就完成了坐标变换。但是因为这个例子要绘制的图形很少，所以还不太能体现使用坐标系变换的好处。不过，你可以想一下，在可视化的许多应用场景中，我们都要处理成百上千的图形。如果这个时候，我们在原始坐标下通过计算顶点来绘制图形，计算量会非常大，很麻烦。那采用坐标变换的方式就是一个很好的优化思路，它能够简化计算量，这不仅让代码更容易理解，也可以节省 CPU 运算的时间。

理解直角坐标系的坐标变换之后，我们再来说说直角坐标系里绘制图形的方法。那不管我们用什么绘图系统绘制图形，一般的几何图形都是由点、线段和面构成。其中，点和线段是基础的图元信息，因此，如何描述它们是绘图的关键。

如何用向量来描述点和线段？

那在直角坐标系下，我们是怎么表示点和线段的呢？我们一般是用向量来表示一个点或者一个线段。

前面的例子因为包含 x、y 两个坐标轴，所以它们构成了一个绘图的平面。因此，我们可以用二维向量来表示这个平面上的点和线段。二维向量其实就是一个包含了两个数值的数组，一个是 x 坐标值，一个是 y 坐标值。

假设，现在这个平面直角坐标系上有一个向量 v。向量 v 有两个含义：一是可以表示该坐标系下位于 (x, y) 处的一个点；二是可以表示从原点 (0,0) 到坐标 (x,y) 的一根线段。

接下来，为了方便你理解，我们先来回顾一下关于向量的数学知识。

首先，向量和标量一样可以进行数学运算。举个例子，现在有两个向量，v1和 v2，如果让它们相加，其结果相当于将 v1向量的终点（x1, y1），沿着 v2向量的方向移动一段距离，这段距离等于 v2向量的长度。这样，我们就可以在平面上得到一个新的点（x1 + x2, y1 + y2），一条新的线段[(0, 0), (x1 + x2, y1 + y2)]，以及一段折线：[(0, 0), (x1, y1) , (x1 + x2, y1 + y2)]。

其次，一个向量包含有长度和方向信息。它的长度可以用向量的 x、y 的平方和的平方根来表示，如果用 JavaScript 来计算，就是：

v.length = function(){return Math.hypot(this.x, this.y)};

它的方向可以用与 x 轴的夹角来表示，即：

v.dir = function() { return Math.atan2(this.y, this.x);}

在上面的代码里，Math.atan2 的取值范围是 -π到π，负数表示在 x 轴下方，正数表示在 x 轴上方。

最后，根据长度和方向的定义，我们还能推导出一组关系式：

v.x = v.length * Math.cos(v.dir);
v.y = v.length * Math.sin(v.dir);

这个推论意味着一个重要的事实：我们可以很简单地构造出一个绘图向量。也就是说，如果我们希望以点 (x0, y0) 为起点，沿着某个方向画一段长度为 length 的线段，我们只需要构造出如下的一个向量就可以了。

这里的α是与 x 轴的夹角，v 是一个单位向量，它的长度为 1。然后我们把向量 (x0, y0) 与这个向量 v1相加，得到的就是这条线段的终点。这么讲还是比较抽象，我们看一个例子。

实战演练：用向量绘制一棵树

我们用前面学到的向量知识来绘制一棵随机生成的树，想要生成的效果如下：

我们还是用 Canvas2D 来绘制。首先是坐标变换，原理前面讲过，我就不细说了。这里，我们要做的变换是将坐标原点从左上角移动到左下角，并且让 y 轴翻转为向上。

ctx.translate(0, canvas.height);
ctx.scale(1, -1);
ctx.lineCap = 'round';

然后，我们定义一个画树枝的函数 drawBranch。

function drawBranch(context, v0, length, thickness, dir, bias) {
  ...
}

这个函数有六个参数：

context 是我们的 Canvas2D 上下文

v0是起始向量

length 是当前树枝的长度

thickness 是当前树枝的粗细

dir 是当前树枝的方向，用与 x 轴的夹角表示，单位是弧度。

bias 是一个随机偏向因子，用来让树枝的朝向有一定的随机性

因为 v0是树枝的起点坐标，那根据前面向量计算的原理，我们创建一个单位向量 (1, 0)，它是一个朝向 x 轴，长度为 1 的向量。然后我们旋转 dir 弧度，再乘以树枝长度 length。这样，我们就能计算出树枝的终点坐标了。代码如下：

 const v = new Vector2D(1, 0).rotate(dir).scale(length);
 const v1 = v0.copy().add(v);

向量的旋转是向量的一种常见操作，对于二维空间来说，向量的旋转可以定义成如下方法（这里我们省略了数学推导过程，有兴趣的同学可以去看一下数学原理）。这个方法我们后面还会经常用到，你先记一下，后续我们讲到仿射变换的时候，会有更详细的解释。

class Vector2D {
  ...
  rotate(rad) {
    const c = Math.cos(rad),
      s = Math.sin(rad);
    const [x, y] = this;

    this.x = x * c + y * -s;
    this.y = x * s + y * c;

    return this;
  }
}

我们可以从一个起始角度开始递归地旋转树枝，每次将树枝分叉成左右两个分枝：

  if(thickness > 2) {
    const left = dir + 0.2;
    drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
    const right = dir - 0.2;
    drawBranch(context, v1, length * 0.9, thickness * 0.8, right, bias * 0.9);
  }

这样，我们得到的就是一棵形状规律的树。

接着我们修改代码，加入随机因子，让迭代生成的新树枝有一个随机的偏转角度。

  if(thickness > 2) {
    const left = Math.PI / 4 + 0.5 * (dir + 0.2) + bias * (Math.random() - 0.5);
    drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
    const right = Math.PI / 4 + 0.5 * (dir - 0.2) + bias * (Math.random() - 0.5);
    drawBranch(context, v1, length * 0.9, thickness * 0.8, right, bias * 0.9);
  }

这样，我们就可以得到一棵随机的树。

最后，为了美观，我们再随机绘制一些花瓣上去，你也可以尝试绘制其他的图案到这棵树上。

  if(thickness < 5 && Math.random() < 0.3) {
    context.save();
    context.strokeStyle = '#c72c35';
    const th = Math.random() * 6 + 3;
    context.lineWidth = th;
    context.beginPath();
    context.moveTo(...v1);
    context.lineTo(v1.x, v1.y - 2);
    context.stroke();
    context.restore();
  }

这样，我们就实现了绘制一棵随机树的方法。

它的完整代码在GitHub 仓库，你可以研究一下。这里面最关键的一步就是前面的向量操作，为了实现向量的 rotate、scale、add 等方法，我封装了一个简单的库 Vector2d.js，你也可以在代码仓库中找到它。

向量运算的意义

实际上，在我们的可视化项目里，直接使用向量的加法、旋转和乘法来构造线段绘制图形的情形并不多。这是因为，在一般情况下，数据在传给前端的时候就已经计算好了，我们只需要拿到数据点的信息，根据坐标变换进行映射，然后直接用映射后的点来绘制图形即可。

既然这样，为什么我们在这里又要强调向量操作的重要性呢？虽然我们很少直接使用向量构造线段来完成绘图，但是向量运算的意义并不仅仅只是用来算点的位置和构造线段，这只是最初级的用法。我们要记住，可视化呈现依赖于计算机图形学，而向量运算是整个计算机图形学的数学基础。

而且，在向量运算中，除了加法表示移动点和绘制线段外，向量的点乘、叉乘运算也有特殊的意义。课后我会给你出一道有挑战性的思考题 ，让你能更深入地理解向量运算的现实意义，在下一节课里我会给你答案。

要点总结

这一节课， 我们以 Canvas 为例学习了坐标变换，以及用向量描述点和线段的原理和方法。

一般来说，采用平面直角坐标系绘图的时候，对坐标进行平移等线性变换，并不会改变坐标系中图形的基本形状和相对位置，因此我们可以利用坐标变换让我们的绘图变得更加容易。Canvas 坐标变换经常会用到 translate 和 scale 这两个变换，它们的操作和原理都很简单，我们根据实际需求来设置就好了。

在平面直角坐标系中，我们可以定义向量来绘图。向量可以表示绘图空间中的一个点，或者连接原点的一条线段。两个向量相加，结果相当于将被加向量的终点沿着加数向量的方向移动一段距离，移动的距离等于加数向量的长度。利用向量的这个特性，我们就能以某个点为起点，朝任意方向绘制线段，从而绘制各种较复杂的几何图形了。

小试牛刀

我们已经知道如何用向量来定义一个线段，你知道如何判断两个线段的位置关系吗？假设有两个线段 l1和 l2，已知它们的起点和终点分别是[(x10, y10),(x11, y11)]、[(x20, y20),(x21, y21)]，你能判断它们的关系吗（小提示：两个线段之间的关系有平行、垂直或既不平行又不垂直）？

已知线段[(x0, y0)、(x1, y1)]，以及一个点 (x2, y2)，怎么求点到线段的距离？

一个平面上放置了一个扫描器，方向延 y 轴方向（该坐标系 y 轴向上），扫描器的视角是 60 度。假设它可以扫描到无限远的地方，那对于平面上给定的任意一个点 (x,y)，我们该如何判断这个点是否处于扫描范围内呢？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

[1]绘制随机树的源代码

[2]坐标变换的源代码

推荐阅读

[1] 二维旋转矩阵与向量旋转推荐文档

[2] 一个有趣的绘图库：Rough.js

[3] Vector2d.js 模块文档

06 | 可视化中你必须要掌握的向量乘法知识

你好，我是月影。

上一节课，我们学习了 Canvas 实现坐标系转换的方法，以及利用向量描述点和线段来绘制基本图形的方法。接下来，我们继续学习和向量有关的绘图知识，教你用数学语言描述曲线。

不过，在讨论如何描述曲线之前，还有一些关于向量乘法的前置知识需要你掌握。虽然它们和如何描述曲线并没有直接的关系，但是使用它们可以计算曲线的控制点、判断曲线的方向以及对曲线进行变换。

因此，向量的乘法在可视化中也是非常重要并且实用的内容。比如，上节课的思考题 3：给你一个任意点，让你判断这个点在不在这个扫描器范围内。这道题可以用我们前面学过的知识解决，但用向量乘法可以更轻松解决。接下来就让我们从这一道题开始今天的学习吧！

如果利用我们前面学过的知识来解题，你可能是直接使用向量的方向定义来做的，代码如下所示。

v.dir = function() {return Math.atan2(this.y, this.x)}

没错，这道题我们可以使用向量的方向来解。因为这里的 dir 是由向量与 x 轴夹角决定的，所以判断点是否在扫描器范围内，我们只需要计算点坐标对应的向量的 dir 值，是否在扫描器的范围内就可以了。代码如下：

const isInRange = v0.dir > Math.PI / 3 && v0.dir < 2 * Math.PI / 3;

这是一个很简单、直观的解法，但是它不够完美，因为这个判断和扫描器的方向有关。什么意思呢？从上面的图中你可以看到，现在它正对着 y 轴正方向，所以角度在π/3 和 2π/3 之间。但如果将它的方向旋转，或者允许它朝向任意的方向，我们就必须要修改对应的角度值了。这个时候就会非常麻烦。

因此，我们会使用一个更通用的解法，也就是利用向量的乘法来解。那具体怎么做呢？别着急，我先带你来复习一下我们高中学过的向量乘法的知识，如果你记得不是特别清楚，正好可以借着这个机会来加深印象。

我们知道，向量乘法有两种，一种是点乘，一种是叉乘，它们有着不同的几何和物理含义。下面，我们一一来看。

向量的点乘

首先，我们来看向量的点乘。

假设，现在有两个 N 维向量 a 和 b，a = [a1, a2, …an]，b = [b1, b2, …bn]，那向量的点积代码如下：

a•b = a1*b1 + a2*b2 + ... + an*bn

在 N 维线性空间中，a、b 向量点积的几何含义，是 a 向量乘以 b 向量在 a 向量上的投影分量。它的物理含义相当于 a 力作用于物体，产生 b 位移所做的功。点积公式如下图所示：

好了，现在你已经知道 a、b 向量点积的定义了。那关于还有两个比较特殊的情况，你需要掌握。第一种是，当 a、b 两个向量平行时，它们的夹角就是 0°，那么 a·b=|a|*|b|，用 JavaScript 代码表示就是：

a.x * b.x + a.y * b.y === a.length * b.length；

第二种是，当 a、b 两个向量垂直时，它们的夹角就是 90°，那么 a·b=0，用 JavaScript 代码表示就是：

a.x * b.x + a.y * b.y === 0;

向量的叉乘

叉乘和点乘有两点不同：首先，向量叉乘运算的结果不是标量，而是一个向量；其次，两个向量的叉积与两个向量组成的坐标平面垂直。怎么理解呢？我们接着往下看。

以二维空间为例，向量 a 和 b 的叉积，就相当于向量 a（蓝色带箭头线段）与向量 b 沿垂直方向的投影（红色带箭头线段）的乘积。那如下图所示，二维向量叉积的几何意义就是向量 a、b 组成的平行四边形的面积。

那叉乘在数学上该怎么计算呢？假设，现在有两个三维向量 a(x1, y1, z1) 和 b(x2, y2, z2)，那么，a 与 b 的叉积可以表示为一个如下图的行列式：

其中 i、j、k 分别是 x、y、z 轴的单位向量。我们把这个行列式展开，就能得到如下公式：

a X b = [y1 * z2 - y2 * z1, - (x1 * z2 - x2 * z1), x1 * y2 - x2 * y1]

我们计算这个公式，得到的值还是一个三维向量，它的方向垂直于 a、b 所在平面。因此，我们刚才说的二维空间中，向量 a、b 的叉积方向就是垂直纸面朝向我们的。那有什么办法可以很容易，就确定出 a、b 的叉积方向呢？

还记得吗？第 2 节课我们提到过左手系和右手系，其中 x 轴向右、y 轴向下的坐标系是右手系。在右手系中求向量 a、b 叉积的方向时，我们可以把右手食指的方向朝向 a，把右手中指的方向朝向 b，那么大拇指所指的方向就是 a、b 叉积的方向，这个方向是垂直纸面向外（即朝向我们）。因此，右手系中向量叉乘的方向就是右手拇指的方向，那左手系中向量叉乘的方向自然就是左手拇指的方向了。

在二维空间里，由于 z 的值为 0，因此我们得到的向量 a X b 的数值，就等于 x1 * y2 - x2 * y1。那它的物理意义是什么呢？二维空间中向量叉乘的物理意义就是 a 和 b 的力矩（力矩你可以理解为一个物体在力的作用下，绕着一个轴转动的趋向。它是一个向量，等于力臂 L 和力 F 的叉乘。这个概念你记住就好了，我们今天不会用到，后面用到的时候我会再详细来讲）。

还记得上一节课的思考题 2，求点到线段的距离吗？在了解了向量叉积的几何意义之后，我们通过向量叉积得到平行四边形面积，再除以底边长，就能得到点到向量所在直线的距离了。是不是很简单？

同样，向量叉积也可以解决思考题 3。那具体该怎么做呢？

首先，对于任意一点 v0，我们先将它归一化。简单来说归一化就是，用 v0的 x、y 分别除以它的绝对值。归一化后的向量方向不变，长度为 1。归一化是向量运算中一个非常重要的操作，用处也非常多。比如说，在向量乘法里，如果 a、b 都是长度为 1 的归一化向量，那么|a X b| 的结果就是 a、b 夹角的正弦值，而|a • b|的结果就是 a、b 夹角的余弦值。这个特性在图形学里用处非常大，你一定要记住它。

好了，再说回来，我们把归一化的向量 a 叉乘扫描器中线上的 v(0,1)，由于扫描器关于 y 轴对称，所以扫描器边缘与 y 轴的夹角是正负 30 度。那么在与单位向量求叉积的时候，就会出现 2 种情况：

点在扫描范围内，如向量 a，就一定满足： |a X v| <= ||a||v|sin(30°)| = |sin(30°)| = 0.5；

点不在扫描范围内，如向量 b，就一定满足：|b X v| > ||b||v|sin(30°)| = |sin(30°)| = 0.5。

因此，只要任意一点所在的向量与单位向量的叉积结果的绝对值不大于 0.5（即 sin30°），就说明这个点在扫描范围内。所以我们可以用如下代码来判断：

const isInRange = Math.abs(new Vec2(0, 1).cross(v0.normalize())) <= 0.5; // v0.normalize()即将v0归一化

好了，关于向量乘法的内容，我们就全部复习完了，相信你已经很好地掌握它们了。不过，我还想再多说几句 ，对于图形学来说，向量运算是基础中的基础，非常重要。所以，我们不仅要熟练掌握，还要学会用向量的思路去解决问题。只有这样，你才能学好图形学，从而成为优秀的可视化工程师。

要点总结

这一节课，我们学习了向量的乘法，包括点乘与叉乘。

其中点乘的几何意义是向量 a 与它在向量 b 所在的轴的投影向量的乘积，物理意义是力向量产生的位移向量所做的功。叉乘的几何意义是向量 a 和向量 b 构成的平行四边形的面积，物理意义是力产生的力矩。

最后，我们还要记住，把向量归一化以后，我们就可以通过向量的点乘与叉乘快速求出向量夹角的正弦和余弦值。

小试牛刀

让我们延续上一节课的第 2 道思考题。已知，平面上有一点 P，它的坐标是 (x0, y0)，还有一条直线，直线上有两个点 Q(x1, y1) 和 R(x2, y2)。你能求出点 P 到直线的距离，以及点 P 到线段 QR 的距离吗？这里面，P、Q、R 这三个值可以是任意的。你可以要试着用一段 JavaScript 代码把计算的过程写出来，最好还能把直线、点和距离在 Canvas 上给直观地绘制出来。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

07 | 如何用向量和参数方程描述曲线？

你好，我是月影。

曲线是图形系统的基本元素之一，它可以构成几何图形的边，也可以描述点和几何体的运动轨迹，还可以控制像素属性的变化。不论我们用什么图形系统绘图，图形的呈现都离不开曲线。因此，对于可视化而言，掌握如何描述曲线是非常重要的。

今天，我们就来学习两种常见的描述曲线的方法，也就是用向量和参数方程来描述曲线。

如何用向量描述曲线？

我们先来说第一种方法，用向量来描述曲线。

我们知道，曲线是可以用折线来模拟的。因此，我们第 5 节课中用向量来绘制折线的方法，同样可以应用于曲线。具体怎么做呢？下面，我就详细来说说。

首先，我们用向量绘制折线的方法来绘制正多边形，我们定义一个函数 regularShape，代码如下：

function regularShape(edges = 3, x, y, step) {
  const ret = [];
  const delta = Math.PI * (1 - (edges - 2) / edges);
  let p = new Vector2D(x, y);
  const dir = new Vector2D(step, 0);
  ret.push(p);
  for(let i = 0; i < edges; i++) {
    p = p.copy().add(dir.rotate(delta));
    ret.push(p);
  }
  return ret;
}

我们在 regularShape 函数中，给定边数 edges、起点 x, y、一条边的长度 step，就可以绘制一个正多边形了。绘制的思路和我们上一节课的思路类似，也就是通过 rotate 旋转向量，然后通过向量加法来计算顶点位置。

具体来说就是，我们定义初始点为 new Vector2D(x, y)，初始方向为 x 轴方向 new Vector2D(step, 0)。然后循环计算正多边形的顶点位置，也就是从初始点开始，每次将方向向量旋转 delta 角度，delta 角度是根据正多边形内角公式计算出来的。最后，我们将当前点和方向向量相加，就得到下一个顶点坐标了。

有了这个方法，我们就可以计算出要绘制的多边形的每一个顶点坐标，然后调用图形系统的 API 将图形绘制出来了。我在下面给出了绘制三角形、六边形、十一边形和六十边形的参数，你可以看一看，也可以试着自己动手绘制一下。

draw(regularShape(3, 128, 128, 100));  // 绘制三角形
draw(regularShape(6, -64, 128, 50));  // 绘制六边形
draw(regularShape(11, -64, -64, 30));  // 绘制十一边形
draw(regularShape(60, 128, -64, 6));  // 绘制六十边形

这些图形用 Canvas2D 绘制出来的结果如下图所示，详细的代码我放在了GitHub 仓库。

从上面的例子中可以看出，当多边形的边数非常多的时候，这个图形就会接近圆。所以，只要利用 regularShape 函数，将多边形的边数设置得很大，我们就可以绘制出圆形了。不过，这个做法虽然能够绘制出圆这样的曲线，但它还有一些缺点。

首先，regularShape 定义边数、起点、一条边的长度，这就和我们通常的使用习惯，也就是定义边数、中心和半径不符。如果我们按照现在这种定义方式绘图，是很难精确对应到图形的位置和大小的。那你可能要说了，不是还可以换算吗？确实可以，但是计算的过程比较繁琐，也很容易出错。

其次，regularShape 可以画圆，改进一下也可以画圆弧，但是对于椭圆、抛物线、贝塞尔曲线等其他曲线的绘制就无能为力了。

那么，为了更简单地绘制出更多的曲线样式，我们需要用更好的模型来描述。

如何用参数方程描述曲线？

接下来，我们就来讨论用参数方程描述曲线的方法。通过这个方法，我们不仅可以描述常见的圆、椭圆、抛物线、正余弦等曲线，还能描述更具有一般性的曲线，也就是没有被数学公式预设好的曲线，比如贝塞尔曲线，或者 Catmull–Rom 曲线等等。

说到参数方程，接下来我在每次用它来画图之前，还是会先带你一起回顾相关的数学知识，这样对你后面的学习也会很方便。那我们先从最简单的曲线，也就是圆形开始，来看看它是如何用参数方程绘制的。

1. 画圆

首先，圆可以用一组参数方程来定义。如下所示的参数方程，定义了一个圆心在（x0,y0），半径为 r 的圆。

知道了方程，下面我们来说一下计算圆顶点的方法。首先，我们实现一个画圆弧的函数 arc，代码如下所示。我们设置圆心为 x0、y0，半径为 radius，起始角度为 startAng，结束角度是 endAng。然后，我们就可以用 draw(arc(0, 0, 100)) 这样的方式在 (0,0) 点绘制一个半径为 100 的圆了。

const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
function arc(x0, y0, radius, startAng = 0, endAng = Math.PI * 2) {
  const ang = Math.min(TAU, endAng - startAng);
  const ret = ang === TAU ? [] : [[x0, y0]];
  const segments = Math.round(TAU_SEGMENTS * ang / TAU);
  for(let i = 0; i <= segments; i++) {
    const x = x0 + radius * Math.cos(startAng + ang * i / segments);
    const y = y0 + radius * Math.sin(startAng + ang * i / segments);
    ret.push([x, y]);
  }
  return ret;
}

draw(arc(0, 0, 100));

这个时候你可能想问，在第 2 节课利用 Canvas2D 画圆的时候，我们使用的 context.arc 方法和我们自己实现的这个函数很像，既然已经有了现成的 API，我们为什么还要自己实现呢？关于这一点，我就要再啰嗦几句了。不是所有的图形系统都提供了画圆的 API，比如 WebGL 中就没有默认的画圆 API。因此，在没有提供画圆的 API 的时候，我们上面实现的函数就可以派上用场了。

2. 画圆锥曲线

除了画圆，参数方程还可以描述很多其他的圆锥曲线。比如椭圆的参数方程。它其实和圆的参数方程很接近。其中，a、b 分别是椭圆的长轴和短轴，当 a = b = r 时，这个方程是就圆的方程式。所以，圆实际上就是椭圆的特例。

再比如，抛物线的参数方程。其中 p 是常数，为焦点到准线的距离。

我们修改上面的 arc 方法中的对应参数，就能同样实现椭圆和抛物线的绘制了。修改的操作非常简单，我就在下面直接给出这两个函数的代码了。

首先是椭圆，它的函数代码如下所示。

const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
function ellipse(x0, y0, radiusX, radiusY, startAng = 0, endAng = Math.PI * 2) {
  const ang = Math.min(TAU, endAng - startAng);
  const ret = ang === TAU ? [] : [[x0, y0]];
  const segments = Math.round(TAU_SEGMENTS * ang / TAU);
  for(let i = 0; i <= segments; i++) {
    const x = x0 + radiusX * Math.cos(startAng + ang * i / segments);
    const y = y0 + radiusY * Math.sin(startAng + ang * i / segments);
    ret.push([x, y]);
  }
  return ret;
}

draw(ellipse(0, 0, 100, 50));

其次是抛物线，它的函数代码如下所示。

const LINE_SEGMENTS = 60;
function parabola(x0, y0, p, min, max) {
  const ret = [];
  for(let i = 0; i <= LINE_SEGMENTS; i++) {
    const s = i / 60;
    const t = min * (1 - s) + max * s;
    const x = x0 + 2 * p * t ** 2;
    const y = y0 + 2 * p * t;
    ret.push([x, y]);
  }
  return ret;
}

draw(parabola(0, 0, 5.5, -10, 10));

3. 画其他常见曲线

除了前面说的圆锥曲线，应用参数方程我们还可以绘制许多比较有趣的曲线，这些曲线在实际工作中，常常用来构建各种几何图形。

不过，如果我们为每一种曲线都分别对应实现一个函数，就会非常笨拙和繁琐。那为了方便，我们可以用函数式的编程思想，封装一个更简单的 JavaScript 参数方程绘图模块，以此来绘制出不同的曲线。这个绘图模块的使用过程主要分为三步。

第一步，我们实现一个叫做 parametric 的高阶函数，它的参数分别是 x、y 坐标和参数方程。

第二步，parametric 会返回一个函数，这个函数会接受几个参数，比如，start、end 这样表示参数方程中关键参数范围的参数，以及 seg 这样表示采样点个数的参数等等。在下面的代码中，当 seg 默认 100 时，就表示在 start、end 范围内采样 101（seg+1）个点，后续其他参数是作为常数传给参数方程的数据。

第三步，我们调用 parametric 返回的函数之后，它会返回一个对象。这个对象有两个属性：一个是 points，也就是它生成的顶点数据；另一个是 draw 方法，我们可以利用这个 draw 方法完成绘图。

这个过程的代码如下：

// 根据点来绘制图形
function draw(points, context, {
  strokeStyle = 'black',
  fillStyle = null,
  close = false,
} = {}) {
  context.strokeStyle = strokeStyle;
  context.beginPath();
  context.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]);
  }
  if(close) context.closePath();
  if(fillStyle) {
    context.fillStyle = fillStyle;
    context.fill();
  }
  context.stroke();
}

export function parametric(xFunc, yFunc) {
  return function (start, end, seg = 100, ...args) {
    const points = [];
    for(let i = 0; i <= seg; i++) {
      const p = i / seg;
      const t = start * (1 - p) + end * p;
      const x = xFunc(t, ...args); // 计算参数方程组的x
      const y = yFunc(t, ...args);  // 计算参数方程组的y
      points.push([x, y]);
    }
    return {
      draw: draw.bind(null, points),
      points,
    };
  };
}

利用绘图模块，我们就可以绘制出各种有趣的曲线了。比如，我们可以很方便地绘制出抛物线，代码如下：

// 抛物线参数方程
const para = parametric(
  t => 25 * t,
  t => 25 * t ** 2,
);

// 绘制抛物线
para(-5.5, 5.5).draw(ctx);

再比如，我们可以绘制出阿基米德螺旋线，代码如下：

const helical = parametric(
  (t, l) => l * t * Math.cos(t),
  (t, l) => l * t * Math.sin(t),
);

helical(0, 50, 500, 5).draw(ctx, {strokeStyle: 'blue'});

以及，我们还可以绘制星形线，代码如下：

const star = parametric(
  (t, l) => l * Math.cos(t) ** 3,
  (t, l) => l * Math.sin(t) ** 3,
);

star(0, Math.PI * 2, 50, 150).draw(ctx, {strokeStyle: 'red'});

同时绘制三条曲线后的效果，如下图所示。详细的代码，我都放到了GitHub 仓库。你可以自己动手试一试，看看怎么把它们组合成更多有趣的图形。

4. 画贝塞尔曲线

前面我们说的这些曲线都比较常见，它们都是符合某种固定数学规律的曲线。但生活中还有很多不规则的图形，无法用上面这些规律的曲线去描述。那我们该如何去描述这些不规则图形呢？贝塞尔曲线（Bezier Curves）就是最常见的一种解决方式。它在可视化领域中也是一类非常常用的曲线，它通过起点、终点和少量控制点，就能定义参数方程来生成复杂的平滑曲线，所以它通常被用来构建数据信息之间连接线。

贝塞尔曲线示意图

贝塞尔曲线又分为二阶贝塞尔曲线（Quadratic Bezier Curve）和三阶贝塞尔曲线（Qubic Bezier Curve）。顾名思义，二阶贝塞尔曲线的参数方程是一元二次多项式，那么三阶贝塞尔曲线的参数方程是一元三次多项式。接下来，我们就分别说说它们的公式和描述曲线的方法

其中，二阶贝塞尔曲线由三个点确定，P0是起点，P1是控制点，P2是终点，示意图如下：

我们可以用 parametric 构建并绘制二阶贝塞尔曲线，代码如下所示：

const quadricBezier = parametric(
  (t, [{x: x0}, {x: x1}, {x: x2}]) => (1 - t) ** 2 * x0 + 2 * t * (1 - t) * x1 + t ** 2 * x2,
  (t, [{y: y0}, {y: y1}, {y: y2}]) => (1 - t) ** 2 * y0 + 2 * t * (1 - t) * y1 + t ** 2 * y2,
);

const p0 = new Vector2D(0, 0);
const p1 = new Vector2D(100, 0);
p1.rotate(0.75);
const p2 = new Vector2D(200, 0);
const count = 30;
for(let i = 0; i < count; i++) {
  // 绘制30条从圆心出发，旋转不同角度的二阶贝塞尔曲线
  p1.rotate(2 / count * Math.PI);
  p2.rotate(2 / count * Math.PI);
  quadricBezier(0, 1, 100, [
    p0,
    p1,
    p2,
  ]).draw(ctx);
}

在上面的代码中，我们绘制了 30 个二阶贝塞尔曲线，它们的起点都是 (0,0)，终点均匀分布在半径 200 的圆上，控制点均匀地分布在半径 100 的圆上。最终，实现的效果如下图所示。详细的代码，你可以访问GitHub 仓库：

二阶贝塞尔曲线效果图

三阶贝塞尔曲线的参数方程为：

可以看到，与二阶贝塞尔曲线相比，三阶贝塞尔曲线有 4 个点，其中 P0和 P3是起点和终点，P1、P2是控制点，所以三阶贝塞尔曲线有两个控制点。

三阶贝塞尔曲线的原理示意图

我们同样可以用 parametric 构建并绘制三阶贝塞尔曲线：

const cubicBezier = parametric(
  (t, [{x: x0}, {x: x1}, {x: x2}, {x: x3}]) => (1 - t) ** 3 * x0 + 3 * t * (1 - t) ** 2 * x1 + 3 * (1 - t) * t ** 2 * x2 + t ** 3 * x3,
  (t, [{y: y0}, {y: y1}, {y: y2}, {y: y3}]) => (1 - t) ** 3 * y0 + 3 * t * (1 - t) ** 2 * y1 + 3 * (1 - t) * t ** 2 * y2 + t ** 3 * y3,
);

const p0 = new Vector2D(0, 0);
const p1 = new Vector2D(100, 0);
p1.rotate(0.75);
const p2 = new Vector2D(150, 0);
p2.rotate(-0.75);
const p3 = new Vector2D(200, 0);
const count = 30;
for(let i = 0; i < count; i++) {
  p1.rotate(2 / count * Math.PI);
  p2.rotate(2 / count * Math.PI);
  p3.rotate(2 / count * Math.PI);
  cubicBezier(0, 1, 100, [
    p0,
    p1,
    p2,
    p3,
  ]).draw(ctx);
}

三阶贝塞尔曲线控制点比二阶贝塞尔曲线多，这有什么优势呢？因为控制点越多，曲线能够模拟出更多不同的形状，也能更精确地控制细节。比如说，在上面的代码中，我们绘制了 30 个三阶贝塞尔曲线，它们的起点都为 (0,0)，终点均匀分布在半径 200 的圆上，控制点 1 均匀分布在半径为 100 的圆上，控制点 2 均匀分布半径 150 的圆上。它和我们之前实现的二阶贝塞尔曲线相比，控制得更细致，形成的图案信息更丰富。

三阶贝塞尔曲线效果图

总的来说，贝塞尔曲线对于可视化，甚至整个计算机图形学都有着极其重要的意义。因为它能够针对一组确定的点，在其中构造平滑的曲线，这也让图形的实现有了更多的可能性。而且，贝塞尔曲线还可以用来构建 Catmull–Rom 曲线。Catmull–Rom 曲线也是一种常用的曲线，它可以平滑折线，我们在数据统计图表中经常会用到它。

使用Catmull–Rom曲线绘制的折线、曲线和部分平滑折线

实际上 Canvas2D 和 SVG 都提供了直接绘制贝塞尔曲线的 API，比如在 Canvas2D 中，我们可以通过创建 Path2D 对象，使用 Path2D 支持的 SVGPath 指令添加贝塞尔曲线。即使如此，我们依然需要掌握贝塞尔曲线的基本原理。因为在 WebGL 这样的图形系统里，我们还是需要自己实现贝塞尔曲线的绘制，而且贝塞尔曲线除了绘制曲线之外，还有其他的用处，比如构建平滑的轨迹动画、属性插值等等。这些内容，我们也会在后续课程中会深入讨论。

要点总结

这一节课我们讨论了用曲线和参数方程描述曲线的方法。

用向量描述比较简单直接，先确定起始点和起始向量，然后通过旋转和向量加法来控制形状，就可以将曲线一段一段地绘制出来。但是它的缺点也很明显，就是数学上不太直观，需要复杂的换算才能精确确定图形的位置和大小。

使用参数方程能够避免向量绘制的缺点，因此是更常用的绘制方式。使用参数方程绘制曲线时，我们既可以使用有规律的曲线参数方程来绘制这些规则曲线，还可以使用二阶、三阶贝塞尔曲线来在起点和终点之间构造平滑曲线。

小试牛刀

Canvas2D 和 SVG 中都提供了画圆、椭圆、贝塞尔曲线的指令，你可以尝试直接使用这些指令来绘制圆、椭圆和贝塞尔曲线，然后比较一下使用这些指令和使用我们课程中讲过的方法有什么不同。

除了圆和椭圆这些常见的参数方程，你还能自己创造出一些参数方程吗？如果可以，你可以使用 parametric.js 把它们绘制出来。

我在课程中，画了两个最基础的贝塞尔曲线。你能试着修改 parametric.js 的代码，调整一下贝塞尔曲线控制点参数，画出更有趣的图形吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

[1]绘制圆锥曲线完整代码.

[2]绘制其他曲线完整代码.

[3]绘制贝塞尔曲线完整代码.

推荐阅读

Parametric.js

08 | 如何利用三角剖分和向量操作描述并处理多边形？

你好，我是月影。

在图形系统中，我们最终看到的丰富多彩的图像，都是由多边形构成的。换句话说，不论是 2D 图形还是 3D 图形，经过投影变换后，在屏幕上输出的都是多边形。因此，理解多边形的基本性质，了解用数学语言描述并且处理多边形的方法，是我们在可视化中必须要掌握的内容。

那今天，我们就来说说，不同的图形系统是如何用数学语言描述并处理多边形。首先，我们来说说图形学中的多边形是什么。

图形学中的多边形是什么？

多边形可以定义为由三条或三条以上的线段首尾连接构成的平面图形，其中，每条线段的端点就是多边形的顶点，线段就是多边形的边。

多边形又可以分为简单多边形和复杂多边形。我们该怎么区分它们呢？如果一个多边形的每条边除了相邻的边以外，不和其他边相交，那它就是简单多边形，否则就是复杂多边形。一般来说，我们在绘图时，要尽量构建简单多边形，因为简单多边形的图形性质比较简单，绘制起来比较方便。

而简单多边形又分为凸多边形和凹多边形，我们主要是看简单多边形的内角来区分的。如果一个多边形中的每个内角都不超过 180°，那它就是凸多边形，否则就是凹多边形。

在图形系统中绘制多边形的时候，最常用的功能是填充多边形，也就是用一种颜色将多边形的内部填满。除此之外，在可视化中用户经常要用鼠标与多边形进行交互，这就要涉及多边形的边界判定。所以今天，我们就来重点讨论多边形的填充和边界判定。首先，我们来看多边形的填充。

不同的图形系统如何填充多边形？

不同的图形系统会用不同的方法来填充多边形。比如说，在 SVG 和 Canvas2D 中，就都内置了填充多边形的 API。在 SVG 中，我们可以直接给元素设置 fill 属性来填充，那在 Canvas2D 中，我们可以在绘图指令结束时调用 fill() 方法进行填充。而在 WebGL 中，我们是用三角形图元来快速填充的。由于 SVG 和 Canvas2D 中的填充方法类似，因此今天，我们就主要说说 Canvas2D 和 WebGL 是怎么填充多边形的。

1. Canvas2D 如何填充多边形？

我们先来说说 Canvas2D 填充多边形的具体方法，可以总结为五步。

第一步，构建多边形的顶点。这里我们直接构造 5 个顶点，代码如下：

const points = [new Vector2D(0, 100)];
for(let i = 1; i <= 4; i++) {
  const p = points[0].copy().rotate(i * Math.PI * 0.4);
  points.push(p);
}

第二步，绘制多边形。我们要用这 5 个顶点分别绘制正五边形和正五角星。显然前者是简单多边形，后者是复杂多边形。那在 Canvas 中，只需将顶点构造出来，我们就可以通过 API 绘制出多边形了。具体绘制代码如下：

const polygon = [
  ...points,
];

// 绘制正五边形
ctx.save();
ctx.translate(-128, 0);
draw(ctx, polygon);
ctx.restore();

const stars = [
  points[0],
  points[2],
  points[4],
  points[1],
  points[3],
];

// 绘制正五角星
ctx.save();
ctx.translate(128, 0);
draw(ctx, stars);
ctx.restore();

如上面代码所示，我们用计算出的 5 个顶点创建 polygon 数组和 stars 数组。其中，polygon 数组是正五边形的顶点数组。stars 数组是我们把正五边形的顶点顺序交换之后，构成的五角星的顶点数组。

接着，我们将这些点传给 draw 函数，在 draw 函数中完成具体的绘制。在 draw 函数中绘制过程的时候，我们是调用 context.fill 来完成填充的。

这里，我要补充一点，不管是简单多边形还是复杂多边形，Canvas2D 的 fill 都能正常填充。并且，Canvas2D 的 fill 还支持两种填充规则。其中，默认的规则是“nonzero”，也就是说 不管有没有相交的边，只要是由边围起来的区域都一律填充。在下面的代码中，我们就是用“nonzero”规则来填充的。

function draw(context, points, {
  fillStyle = 'black',
  close = false,
  rule = 'nonzero',
} = {}) {
  context.beginPath();
  context.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]);
  }
  if(close) context.closePath();
  context.fillStyle = fillStyle;
  context.fill(rule);
}

我们最终绘制出的效果如下图所示：

简单多边形a和b

除了“nonzero”，还有一种规则叫做“evenodd”，它是根据重叠区域是奇数还是偶数来判断是否填充的。那当我们增加了 draw 方法的参数，将五角星的填充规则改成“evenodd”之后，简单多边形没有变化，而复杂多边形由于绘制区域存在重叠，就出导致图形中心有了空洞的特殊效果。

draw(ctx, stars, {rule: 'evenodd'});

使用evenodd之后得到的填充图形a和b

总之，Canvas2D 的 fill 非常实用，它可以自动填充多边形内部的区域，并且对于任何多边形都能判定和填充，你可以自己去尝试一下。

2. WebGL 如何填充多边形？

在 WebGL 中，虽然没有提供自动填充多边形的方法，但是我们可以用三角形这种基本图元来快速地填充多边形。因此，在 WebGL 中填充多边形的第一步，就是将多边形分割成多个三角形。

这种将多边形分割成若干个三角形的操作，在图形学中叫做三角剖分（Triangulation）。

同一个多边形的两种三角剖分方法

三角剖分是图形学和代数拓扑学中一个非常重要的基本操作，也有很多不同的实现算法。对简单多边形尤其是凸多边形的三角剖分比较简单，而复杂多边形由于有边的相交和面积重叠区域，所以相对困难许多。

那因为这些算法讲解起来比较复杂，还会涉及很多图形学的底层数学知识，你可能很难理解，所以我就不详细说三角剖分的具体算法了。如果你有兴趣学习，可以自己花一点时间去看一些参考资料。

这里，我们就直接利用 GitHub 上的一些成熟的库（常用的如Earcut、Tess2.js以及cdt2d），来对多边形进行三角剖分就可以了。具体怎么做呢？接下来，我们就以最简单的 Earcut 库为例，来说一说 WebGL 填充多边形的过程。

简单多边形c

假设，我们要填充一个如上图所示的不规则多边形，它的顶点数据如下：

const vertices = [
  [-0.7, 0.5],
  [-0.4, 0.3],
  [-0.25, 0.71],
  [-0.1, 0.56],
  [-0.1, 0.13],
  [0.4, 0.21],
  [0, -0.6],
  [-0.3, -0.3],
  [-0.6, -0.3],
  [-0.45, 0.0],
];

首先，我们要对它进行三角剖分。使用 Earcut 库的操作很简单，我们直接调用它的 API 就可以完成对多边形的三角剖分，具体代码如下：

import {earcut} from '../common/lib/earcut.js';

const points = vertices.flat();
const triangles = earcut(points);

因为 Earcut 库只接受扁平化的定点数据，所以我们先用了数组的 flat 方法将顶点扁平化，然后将它传给 Earcut 进行三角剖分。这样返回的结果是一个数组，这个数组的值是顶点数据的 index，结果如下：

 [1, 0, 9, 9, 8, 7, 7, 6, 5, 4, 3, 2, 2, 1, 9, 9, 7, 5, 4, 2, 9, 9, 5, 4]

这里的值，比如 1 表示 vertices 中下标为 1 的顶点，即点 (-0.4, 0.3)，每三个值可以构成一个三角形，所以 1、0、9 表示由 (-0.4, 0.3)、(-0.7, 0.5) 和 (-0.45, 0.0) 构成的三角形。

然后，我们将顶点和 index 下标数据都输入到缓冲区，通过 gl.drawElements 方法就可以把图形显示出来。具体的代码如下：

const position = new Float32Array(points);
const cells = new Uint16Array(triangles);

const pointBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);

const vPosition = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vPosition);

const cellsBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cellsBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, cells, gl.STATIC_DRAW);

gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, cells.length, gl.UNSIGNED_SHORT, 0);

你会发现，通过上面的步骤，整个多边形都被 WebGL 渲染并填充为了红色。这么一看，好像三角剖分并没有什么作用。但实际上，WebGL 是对这个多边形三角剖分后的每个三角形分别进行填充的。为了让你看得更清楚，我们用描边代替填充，具体操作就是，修改一下 gl.drawElements 的渲染模式，将 gl.TRIANGLES 改成 gl.LINE_STRIP。这样，我们就可以清晰地看出，经过 Earcut 处理的这个多边形被分割成了 8 个三角形。

到这里，我们就讲完了 2D 图形的三角剖分。那针对 3D 模型，WebGL 在绘制的时候，也需要使用三角剖分，而 3D 的三角剖分又被称为网格化（Meshing）。

不过，因为 3D 模型比 2D 模型更加复杂，顶点的数量更多，所以针对复杂的 3D 模型，我们一般不在运行的时候进行三角剖分，而是通过设计工具把图形的三角剖分结果直接导出进行使用。也就是说，在 3D 渲染的时候，我们一般使用的模型数据都是已经经过三角剖分以后的顶点数据。

那如果必须要在可视化项目中实时创建一些几何体的时候，我们该怎么办呢？这部分内容，我们会在视觉篇详细来讲，不过在那之前呢，你也可以自己先想想。

总的来说，无论是绘制 2D 还是 3D 图形，WebGL 都需要先把它们进行三角剖分，然后才能绘制。因此，三角剖分是 WebGL 绘图的基础。

如何判断点在多边形内部？

接下来，我们通过一个简单的例子来说说多边形的交互。这个例子要实现的效果其实就是，当用户的鼠标移动到某一个图形上时，我们要让这个图形变色。在这个例子中，我们要解决的核心问题是：判定鼠标所在位置是否在多边形的内部。

那么问题来了，不同的图形系统都是如何判断点在多边形内部的呢？

在 SVG 这样的图形系统里，由于多边形本身就是一个元素节点，因此我们直接通过 DOM API 就可以判定鼠标是否在该元素上。而对于 Canvas2D，我们不能直接通过 DOM API 判定，而是要通过 Canvas2D 提供的 isPointInPath 方法来判定。所以下面，我们就以多边形 c 为例，来详细说说这个过程。

1. Canvas2D 如何判断点在多边形内部？

首先，我们先改用 Canvas2D 来绘制并填充这个多边形。

然后，我们在 canvas 上添加 mousemove 事件，在事件中计算鼠标相对于 canvas 的位置，再将这个位置传给 isPointInPath 方法，isPointInPath 方法就会自动判断这个位置是否位于图形内部。代码如下：

const {left, top} = canvas.getBoundingClientRect();

canvas.addEventListener('mousemove', (evt) => {
  const {x, y} = evt;
  // 坐标转换
  const offsetX = x - left;
  const offsetY = y - top;

  ctx.clearRect(-256, -256, 512, 512);

  if(ctx.isPointInPath(offsetX, offsetY)) {
    draw(ctx, poitions, 'transparent', 'green');
  } else {
    draw(ctx, poitions, 'transparent', 'red');
  }
});

最后，上面代码运行效果如下图：

这个运行结果是没有问题的，但 isPointInPath 这个方法实际上并不好用。因为 isPointInPath 方法只能对当前绘制的图形生效。这是什么意思呢？我来举个例子。

假设，我们要在 Canvas 中绘制多边形 c 和小三角形。那我们先绘制多边形 c，再绘制小三角形。绘制代码如下：

draw(ctx, poitions, 'transparent', 'red');
draw(ctx, [[100, 100], [100, 200], [150, 200]], 'transparent', 'blue');

const {left, top} = canvas.getBoundingClientRect();

canvas.addEventListener('mousemove', (evt) => {
  const {x, y} = evt;
  // 坐标转换
  const offsetX = x - left;
  const offsetY = y - top;

  ctx.clearRect(-256, -256, 512, 512);

  // 判断 offsetX、offsetY 的坐标是否在多边形内部
  if(ctx.isPointInPath(offsetX, offsetY)) {
    draw(ctx, poitions, 'transparent', 'green');
    draw(ctx, [[100, 100], [100, 200], [150, 200]], 'transparent', 'orange');
  } else {
    draw(ctx, poitions, 'transparent', 'red');
    draw(ctx, [[100, 100], [100, 200], [150, 200]], 'transparent', 'blue');
  }
});

这里，我们还通过 isPointInPath 方法判断点的位置，这样得到的结果如下图：

你会看到，当我们将鼠标移动到中间大图时，它的颜色并没有发生变化，只有移动到右上角的小三角形时，这两个图形才会同时变色。这就是因为，isPointInPath 仅能判断鼠标是否在最后一次绘制的小三角形内，所以大多边形就没有被识别出来。

要解决这个问题，一个最简单的办法就是，我们自己实现一个 isPointInPath 方法。然后在这个方法里，重新创建一个 Canvas 对象，并且再绘制一遍多边形 c 和小三角形。这个方法的核心，其实就是在绘制的过程中获取每个图形的 isPointInPath 结果。代码如下：

function isPointInPath(ctx, x, y) {
  // 我们根据ctx重新clone一个新的canvas对象出来
  const cloned = ctx.canvas.cloneNode().getContext('2d');
  cloned.translate(0.5 * width, 0.5 * height);
  cloned.scale(1, -1);
  let ret = false;
  // 绘制多边形c，然后判断点是否在图形内部
  draw(cloned, poitions, 'transparent', 'red');
  ret |= cloned.isPointInPath(x, y);
  if(!ret) {
    // 如果不在，在绘制小三角形，然后判断点是否在图形内部
    draw(cloned, [[100, 100], [100, 200], [150, 200]], 'transparent', 'blue');
    ret |= cloned.isPointInPath(x, y);
  }
  return ret;
}

但是，这个方法并不通用。因为一旦我们修改了绘图过程，也就是增加或者减少了绘制的图形，isPointInPath 方法也要跟着改变。当然，我们也有办法进行优化，比如将每一个几何图形的绘制封装起来，针对每个图形提供单独的 isPointInPath 判断，但是这样也很麻烦，而且有很多无谓的 Canvas 绘图操作，性能会很差。

2. 实现通用的 isPointInPath 方法

那一个更好的办法是，我们不使用 Canvas 的 isPointInPath 方法，而是直接通过点与几何图形的数学关系来判断点是否在图形内。但是，直接判断一个点是不是在一个几何图形内还是比较困难的，因为这个几何图形可能是简单多边形，也可能是复杂多边形。

这个时候，我们完全可以把视线放在最简单的多边形，也就是三角形上。因为对于三角形来说，我们有一个非常简单的方法可以判断点是否在其中。

这个方法就是，已知一个三角形的三条边分别是向量 a、b、c，平面上一点 u 连接三角形三个顶点的向量分别为 u1、u2、u3，那么 u 点在三角形内部的充分必要条件是：u1 X a、u2 X b、u3 X c 的符号相同。

你也可以看我下面给出的示意图，当点 u 在三角形 a、b、c 内时，因为 u1到 a、u2到 b、u3到 c 的小角旋转方向是相同的（这里都为顺时针），所以 u1 X a、u2 X b、u3 X c 要么同正，要么同负。当点 v 在三角形外时，v1到 a 方向是顺时针，v2到 b 方向是逆时针，v3到 c 方向又是顺时针，所以它们叉乘的结果符号并不相同。

根据这个原理，我们就可以写一个简单的判定函数了，代码如下：

function inTriangle(p1, p2, p3, point) {
  const a = p2.copy().sub(p1);
  const b = p3.copy().sub(p2);
  const c = p1.copy().sub(p3);

  const u1 = point.copy().sub(p1);
  const u2 = point.copy().sub(p2);
  const u3 = point.copy().sub(p3);

  const s1 = Math.sign(a.cross(u1));
  const s2 = Math.sign(b.cross(u2));
  const s3 = Math.sign(c.cross(u3));

  return s1 === s2 && s2 === s3;
}

你以为到这里就结束了吗？还没有。上面的代码还有个 Bug，它虽然可以判定点在三角形内部，但却不能判定点恰好在三角形某条边上的情况。这又该如何优化呢？

在学习了向量乘法之后，我们知道。如果一个点 u 在三角形的一条边 a 上，那就会需要满足以下 2 个条件：

a.cross(u1) === 0

0 <= a.dot(u1) / a.length ** 2 <= 1

第一个条件很容易理解，我就不细说了，我们重点来看第二个条件。下面，我就分别讨论一下点 u 和 a 在一条直线上和不在一条直线上这两种情况。

左图是点u和a不在一条直线上，右图是点u和a在一条直线上

当向量 u1与 a 不在一条直线上时，u1与 a 的叉乘结果不为 0，而 u1与 a 的点乘的值除以 a 的长度，相当于 u1在 a 上的投影。

当向量 u1与 a 在一条直线上时，u1与 a 的叉乘结果为 0，u1与 a 的点乘结果除以 a 的长度的平方，正好是 u1与 a 的比值。

u1与 a 的比值也有三种情况：当 u1在 a 上时，u1和 a 比值是介于 0 到 1 之间的；当 u1在 a 的左边时，这个比值是小于 0 的；当 u1 在 a 的右边时，这个比值是大于 1 的。

因此，只有当 u1 和 a 的比值在 0 到 1 之间时，才能说明点在三角形的边上。

好了，那接下来，我们可以根据得到的结果修改一下代码。我们最终的判断逻辑如下：

function inTriangle(p1, p2, p3, point) {
  const a = p2.copy().sub(p1);
  const b = p3.copy().sub(p2);
  const c = p1.copy().sub(p3);

  const u1 = point.copy().sub(p1);
  const u2 = point.copy().sub(p2);
  const u3 = point.copy().sub(p3);

  const s1 = Math.sign(a.cross(u1));
  let p = a.dot(u1) / a.length ** 2;
  if(s1 === 0 && p >= 0 && p <= 1) return true;

  const s2 = Math.sign(b.cross(u2));
  p = b.dot(u1) / b.length ** 2;
  if(s2 === 0 && p >= 0 && p <= 1) return true;

  const s3 = Math.sign(c.cross(u3));
  p = c.dot(u1) / c.length ** 2;
  if(s3 === 0 && p >= 0 && p <= 1) return true;

  return s1 === s2 && s2 === s3;
}

这样我们就判断了一个点是否在某个三角形内部。那如果要判断一个点是否在任意多边形的内部，我们只需要在判断之前将它进行三角剖分就可以了。代码如下：

function isPointInPath({vertices, cells}, point) {
  let ret = false;
  for(let i = 0; i < cells.length; i += 3) {
    const p1 = new Vector2D(...vertices[cells[i]]);
    const p2 = new Vector2D(...vertices[cells[i + 1]]);
    const p3 = new Vector2D(...vertices[cells[i + 2]]);
    if(inTriangle(p1, p2, p3, point)) {
      ret = true;
      break;
    }
  }
  return ret;
}

要点总结

本节课，我们学习了使用三角剖分来填充多边形以及判断点是否在多边形内部。

不同的图形系统有着不同的处理方法，Canvas2D 的处理很简单，它可以使用原生的 fill 来填充任意多边形，使用 isPointInPath 来判断点是否在多边形内部。但是，三角剖分是更加通用的方式，WebGL 就是使用三角剖分来处理多边形的，所以我们要牢记它的操作。

首先，在使用三角剖分填充多边形时，我们直接调用一些成熟库的 API 就可以完成，这并不难。而当我们要实现图形和用户的交互时，也就是要判断一个点是否在多边形内部时，也需要先对多边形进行三角剖分，然后判断该点是否在其中一个三角形内部。

小试牛刀

在课程中，我们使用了 Earcut 对多边形进行三角剖分。但是 tess2.js 是一个比 Earcut 更强大的三角剖分库，使用 tess2.js 可以像原生的 Canvas2D 的 fill 方法那样，实现 evenodd 的填充规则。你能试着把代码中的 earcut 换成 tess2.js，从而实现 evenodd 填充规则吗？动手之前，你可以先去读一下 tess2.js 的项目文档。

今天我们用三角剖分实现了不规则多边形。那你能试着利用三角剖分的原理，通过 WebGL 画出椭圆图案、菱形的星星图案（✦），以及正五角星吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

使用三角剖分填充多边形、判断点在多边形内部的完整代码

推荐阅读

tess2.js 官方文档

09 | 如何用仿射变换对几何图形进行坐标变换？

你好，我是月影。

前面两节课，我们学习了用向量表示的顶点，来描述曲线和多边形的方法。但是在实际绘制的时候，我们经常需要在画布上绘制许多轮廓相同的图形，难道这也需要我们重复地去计算每个图形的顶点吗？当然不需要。我们只需要创建一个基本的几何轮廓，然后通过仿射变换来改变几何图形的位置、形状、大小和角度。

仿射变换是拓扑学和图形学中一个非常重要的基础概念。利用它，我们才能在可视化应用中快速绘制出形态、位置、大小各异的众多几何图形。所以，这一节课，我们就来说一说仿射变换的数学基础和基本操作，它几乎会被应用到我们后面讲到的所有视觉呈现的案例中，所以你一定要掌握。

什么是仿射变换？

仿射变换简单来说就是“线性变换 + 平移”。实际上在平常的 Web 开发中，我们也经常会用到仿射变换，比如，对元素设置 CSS 的 transform 属性就是对元素应用仿射变换。

再说回到几何图形，针对它的仿射变换具有以下 2 个性质：

仿射变换前是直线段的，仿射变换后依然是直线段

对两条直线段 a 和 b 应用同样的仿射变换，变换前后线段长度比例保持不变

由于仿射变换具有这两个性质，因此对线性空间中的几何图形进行仿射变换，就相当于对它的每个顶点向量进行仿射变换。

那具体怎么操作呢？下面，我们就来详细说说。

向量的平移、旋转与缩放

常见的仿射变换形式包括平移、旋转、缩放以及它们的组合。其中，平移变换是最简单的仿射变换。如果我们想让向量 P(x0, y0) 沿着向量 Q(x1, y1) 平移，只要将 P 和 Q 相加就可以了。

平移后的向量p的坐标

接着是旋转变换。实际上，旋转变换我们在第 5 课接触过，当时我们把向量的旋转定义成了如下的函数：

class Vector2D {
  ...
  rotate(rad) {
    const c = Math.cos(rad),
      s = Math.sin(rad);
    const [x, y] = this;

    this.x = x * c + y * -s;
    this.y = x * s + y * c;

    return this;
  }
}

但是，我们并没有讨论这个函数是怎么来的，那在这里我们通过三角函数来详细推导一下。

假设向量 P 的长度为 r，角度是⍺，现在我们要将它顺时针旋转⍬角，此时新的向量 P’的参数方程为：

然后，因为 rcos⍺、rsin⍺是向量 P 原始的坐标 x0、y0，所以，我们可以把坐标代入到上面的公式中，就会得到如下的公式：

最后，我们再将它写成矩阵形式，就会得到一个旋转矩阵。至于为什么要写成矩阵形式，我后面会讲，这里你先记住这个旋转矩阵的公式就可以了。

然后是缩放变换。缩放变换也很简单，我们可以直接让向量与标量（标量只有大小、没有方向）相乘。

对于得到的这个公式，我们也可以把它写成矩阵形式。结果如下：

现在，我们就得到了三个基本的仿射变换公式，其中旋转和缩放都可以写成矩阵与向量相乘的形式。这种能写成矩阵与向量相乘形式的变换，就叫做线性变换。线性变换除了可以满足仿射变换的 2 个性质之外，还有 2 个额外的性质：

线性变换不改变坐标原点（因为如果 x0、y0等于零，那么 x、y 肯定等于 0）；

线性变换可以叠加，多个线性变换的叠加结果就是将线性变换的矩阵依次相乘，再与原始向量相乘。

那根据线性变换的第 2 条性质，我们就能总结出一个通用的线性变换公式，即一个原始向量 P0经过 M1、M2、…Mn 次的线性变换之后得到最终的坐标 P。线性变化的叠加是一个非常重要的性质，它是我们对图形进行变换的基础，所以你一定要牢记线性变化的叠加性质。

好了，常见的仿射变换形式我们说完了。总的来说，向量的基本仿射变换分为平移、旋转与缩放，其中旋转与缩放属于线性变换，而平移不属于线性变换。基于此，我们可以得到仿射变换的一般表达式，如下图所示：

仿射变换的公式优化

上面这个公式我们还可以改写成矩阵的形式，在改写的公式里，我们实际上是给线性空间增加了一个维度。换句话说，我们用高维度的线性变换表示了低维度的仿射变换！

这样，我们就将原本 n 维的坐标转换为了 n+1 维的坐标。这种 n+1 维坐标被称为齐次坐标，对应的矩阵就被称为齐次矩阵。

齐次坐标和齐次矩阵是可视化中非常常用的数学工具，它能让我们用线性变换来表示仿射变换。这样一来，我们就能利用线性变换的叠加性质，来非常方便地进行各种复杂的仿射变换了。落实到共识上，就是把这些变换的矩阵相乘得到一个新的矩阵，再把它乘以原向量。我们在绘制几何图形的时候会经常用到它，所以你要记住这个公式。

仿射变换的应用：实现粒子动画

好了，现在你已经知道了仿射变换的数学基础。那它该怎么应用呢？一个很常见的应用，就是利用它来实现粒子动画。

你可能还不熟悉粒子动画，我们先来快速认识一下它。它能在一定时间内生成许多随机运动的小图形，这类动画通常是通过给人以视觉上的震撼，来达到获取用户关注的效果。在可视化中，粒子动画可以用来表达数据信息本身（比如数量、大小等等），也可以用来修饰界面、吸引用户的关注，它是我们在可视化中经常会用到的一种视觉效果。

在粒子动画的实现过程中，我们通常需要在界面上快速改变一大批图形的大小、形状和位置，所以用图形的仿射变换来实现是一个很好的方法。

为了方便你理解，我们今天只讲一个简单的粒子动画。这个粒子动画的运行效果，是从一个点开始发射出许多颜色、大小、角度各异的三角形，并且通过不断变化它们的位置，产生一种撒花般的视觉效果。

1. 创建三角形

因为这个粒子动画中主要用到了三角形，所以我们第一步就要创建三角形。创建三角形一共可以分为两步，第一步，我们定义三角形的顶点并将数据送到缓冲区。这一步，你直接看下面创建 WebGLProgram 的步骤就能理解。如果你还不是很熟悉，我建议你复习一下第 4 节课的内容。

const position = new Float32Array([
  -1, -1,
  0, 1,
  1, -1,
]);
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);

const vPosition = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vPosition);

第二步，我们实现一个创建随机三角形属性的函数。具体来说就是，利用 randomTriangles 随机创建一个三角形的信息，其中的参数包括颜色 u_color、初始旋转角度 u_rotation、初始大小 u_scale、初始时间 u_time、动画持续时间 u_diration、运动方向 u_dir 和创建时间 startTime。除了 startTime 之外的数据，我们都需要传给 shader 去处理。

function randomTriangles() {
  const u_color = [Math.random(), Math.random(), Math.random(), 1.0]; // 随机颜色
  const u_rotation = Math.random() * Math.PI; // 初始旋转角度
  const u_scale = Math.random() * 0.05 + 0.03; // 初始大小
  const u_time = 0;
  const u_duration = 3.0; // 持续3秒钟

  const rad = Math.random() * Math.PI * 2;
  const u_dir = [Math.cos(rad), Math.sin(rad)]; // 运动方向
  const startTime = performance.now();

  return {u_color, u_rotation, u_scale, u_time, u_duration, u_dir, startTime};
}

2.  设置 uniform 变量

通过前面的代码，我们已经将三角形顶点信息传入缓冲区。我们知道，在 WebGL 的 shader 中，顶点相关的变量可以用 attribute 声明。但是，我们现在要把 u_color、u_rotation 等一系列变量也传到 shader 中，这些变量与三角形具体顶点无关，它们是一些固定的值。这时候，我们就要用到 shader 的另一种变量声明，也就是 uniform 来声明。

那它们有什么区别呢？首先，attribute 变量是对应于顶点的。也就是说，几何图形有几个顶点就要提供几份 attribute 数据。并且，attribute 变量只能在顶点着色器中使用，如果要在片元着色器中使用，需要我们通过 varying 变量将它传给片元着色器才行。这样一来，片元着色器中获取的实际值，就是经过顶点线性插值的。

而 uniform 声明的变量不同，uniform 声明的变量和其他语言中的常量一样，我们赋给 unform 变量的值在 shader 执行的过程中不可改变。而且一个变量的值是唯一的，不随顶点变化。uniform 变量既可以在顶点着色器中使用，也可以在片元着色器中使用。

在 WebGL 中，我们可以通过 gl.uniformXXX(loc, u_color); 的方法将数据传给 shader 的 uniform 变量。其中，XXX 是我们随着数据类型不同取得不同的名字。我在下面列举了一些比较常用的，你可以看看：

gl.uniform1f 传入一个浮点数，对应的 uniform 变量的类型为 float

gl.uniform4f 传入四个浮点数，对应的 uniform 变量类型为 float[4]

gl.uniform3fv 传入一个三维向量，对应的 uniform 变量类型为 vec3

gl.uniformMatrix4fv 传入一个 4x4 的矩阵，对应的 uniform 变量类型为 mat4

今天，关于 WebGL 的 uniform 的设置，我们只需要知道这个最常用的方法就可以了，更详细的设置信息，你可以参考MDN 官方文档。

接下来，我们实现这个函数，将随机三角形信息传给 shader 里的 uniform 变量。代码如下：

function setUniforms(gl, {u_color, u_rotation, u_scale, u_time, u_duration, u_dir}) {
  // gl.getUniformLocation 拿到uniform变量的指针
  let loc = gl.getUniformLocation(program, 'u_color');
  // 将数据传给 unfirom 变量的地址
  gl.uniform4fv(loc, u_color);

  loc = gl.getUniformLocation(program, 'u_rotation');
  gl.uniform1f(loc, u_rotation);

  loc = gl.getUniformLocation(program, 'u_scale');
  gl.uniform1f(loc, u_scale);

  loc = gl.getUniformLocation(program, 'u_time');
  gl.uniform1f(loc, u_time);

  loc = gl.getUniformLocation(program, 'u_duration');
  gl.uniform1f(loc, u_duration);

  loc = gl.getUniformLocation(program, 'u_dir');
  gl.uniform2fv(loc, u_dir);
}

3. 用 requestAnimationFrame 实现动画

然后，我们使用 requestAnimationFrame 实现动画。具体的方法就是，我们在 update 方法中每次新建数个随机三角形，然后依次修改所有三角形的 u_time 属性，通过 setUniforms 方法将修改的属性更新到 shader 变量中。这样，我们就可以在 shader 中读取变量的值进行处理了。代码如下：

let triangles = [];

function update() {
  for(let i = 0; i < 5 * Math.random(); i++) {
    triangles.push(randomTriangles());
  }
  gl.clear(gl.COLOR_BUFFER_BIT);
  // 对每个三角形重新设置u_time
  triangles.forEach((triangle) => {
    triangle.u_time = (performance.now() - triangle.startTime) / 1000;
    setUniforms(gl, triangle);
    gl.drawArrays(gl.TRIANGLES, 0, position.length / 2);
  });
  // 移除已经结束动画的三角形
  triangles = triangles.filter((triangle) => {
    return triangle.u_time <= triangle.u_duration;
  });
  requestAnimationFrame(update);
}

requestAnimationFrame(update);

我们再回过头来看最终要实现的效果。你会发现，所有的三角形，都是由小变大朝着特定的方向旋转。那想要实现这个效果，我们就需要用到前面讲过的仿射变换，在顶点着色器中进行矩阵运算。

在这一步中，顶点着色器中的 glsl 代码最关键，我们先来看一下这个代码是怎么写的。

attribute vec2 position;

uniform float u_rotation;
uniform float u_time;
uniform float u_duration;
uniform float u_scale;
uniform vec2 u_dir;

varying float vP;

void main() {
  float p = min(1.0, u_time / u_duration);
  float rad = u_rotation + 3.14 * 10.0 * p;
  float scale = u_scale * p * (2.0 - p);
  vec2 offset = 2.0 * u_dir * p * p;
  mat3 translateMatrix = mat3(
    1.0, 0.0, 0.0,
    0.0, 1.0, 0.0,
    offset.x, offset.y, 1.0
  );
  mat3 rotateMatrix = mat3(
    cos(rad), sin(rad), 0.0,
    -sin(rad), cos(rad), 0.0,
    0.0, 0.0, 1.0
  );
  mat3 scaleMatrix = mat3(
    scale, 0.0, 0.0,
    0.0, scale, 0.0,
    0.0, 0.0, 1.0
  );
  gl_PointSize = 1.0;
  vec3 pos = translateMatrix * rotateMatrix * scaleMatrix * vec3(position, 1.0);
  gl_Position = vec4(pos, 1.0);
  vP = p;
}

其中有几个关键参数，你可能还比较陌生，我来分别介绍一下。

首先，我们定义的 p 是当前动画进度，它的值是 u_time / u_duration，取值区间从 0 到 1。rad 是旋转角度，它的值是初始角度 u_rotation 加上 10π，表示在动画过程中它会绕自身旋转 5 周。

其次，scale 是缩放比例，它的值是初始缩放比例乘以一个系数，这个系数是 p * (2.0 - p)，在我们后面讨论动画的时候你会知道，p * (2.0 - p) 是一个缓动函数，在这里我们只需要知道，它的作用是让 scale 的变化量随着时间推移逐渐减小就可以了。

最后，offset 是一个二维向量，它是初始值 u_dir 与 2.0 * p * p 的乘积，因为 u_dir 是个单位向量，这里的 2.0 表示它的最大移动距离为 2，p * p 也是一个缓动函数，作用是让位移的变化量随着时间增加而增大。

定义完这些参数以后，我们得到三个齐次矩阵：translateMatrix 是偏移矩阵，rotateMatrix 是旋转矩阵，scaleMatrix 是缩放矩阵。我们将 pos 的值设置为这三个矩阵与 position 的乘积，这样就完成对顶点的线性变换，呈现出来的效果也就是三角形会向着特定的方向旋转、移动和缩放。

4. 在片元着色器中着色

最后，我们在片元着色器中对这些三角形着色。我们将 p 也就是动画进度，从顶点着色器通过变量 varying vP 传给片元着色器，然后在片元着色器中让 alpha 值随着 vP 值变化，这样就能同时实现粒子的淡出效果了。

片元着色器中的代码如下：

 precision mediump float;
  uniform vec4 u_color;
  varying float vP;

  void main()
  {
    gl_FragColor.xyz = u_color.xyz;
    gl_FragColor.a = (1.0 - vP) * u_color.a;
  }

到这里，我们就用仿射变换实现了一个有趣的粒子动画。

CSS 的仿射变换

既然我们讲了仿射变换，这里还是要再提一下 CSS 中我们常用的属性 transform。

div.block {
  transform: rotate(30deg) translate(100px,50px) scale(1.5);
}

CSS 中的 transform 是一个很强大的属性，它的作用其实也是对元素进行仿射变换。

它不仅支持 translate、rotate、scale 等值，还支持 matrix。CSS 的 matrix 是一个简写的齐次矩阵，因为它省略了 3 阶齐次矩阵第三行的 0, 0, 1 值，所以它 只有 6 个值。

transform 在 CSS 中变换元素的方法，我们作为前端工程师都比较熟悉了。但你知道怎么优化它来提高性能吗？下面，我就重点来说说这一点。

结合上面介绍的齐次矩阵变换的原理，我们可以对 CSS 的 transform 属性进行压缩。举个例子，我们可以这么定义 CSS transform，代码如下：

div.block {
  transform: rotate(30deg) translate(100px,50px) scale(1.5);
}

也就是我们先旋转 30 度，然后平移 100px、50px，最后再放大 1.5 倍。实际上相当于我们做了如下变换：

这里我就不再自己写矩阵乘法的库了，我们用一个向量矩阵运算的数学库 math，它几乎包含了所有图形学需要用到的数学方法，我们在后面课程中也会经常用到它，你可以参考GitHub 仓库先了解一下它。

我们简单算一下三个矩阵相乘，代码如下：

import {multiply} from 'common/lib/math/functions/mat3fun.js';

const rad = Math.PI / 6;
const a = [
  Math.cos(rad), -Math.sin(rad), 0,
  Math.sin(rad), Math.cos(rad), 0,
  0, 0, 1
];

const b = [
  1, 0, 100,
  0, 1, 50,
  0, 0, 1
];

const c = [
  1.5, 0, 0,
  0, 1.5, 0,
  0, 0, 1
];

const res = [a, b, c].reduce((a, b) => {
  return multiply([], b, a);
});

console.log(res);
/*
[1.299038105676658, -0.7499999999999999, 61.60254037844388,
  0.7499999999999999, 1.299038105676658, 93.30127018922192,
  0, 0, 1]
*/

所以呢，我们最终就可以将上面的 transform 用一个矩阵表示：

div.block {
  transform: matrix(1.3,0.75,-0.75,1.3,61.6,93.3)；
}

这样的 transform 效果和之前 rotate、translate 和 scale 分开写的效果是一样的，但是字符数更少，所以能减小 CSS 文件的大小。

那在我们介绍完仿射变换之后，你是不是对 CSS transform 的理解也更深了呢？没错，不光是 transform，在我们之后的学习中，你也可以多想想，还有哪些内容在 CSS 中也有相似的作用，是不是也能利用在可视化中学到的知识来优化性能。

要点总结

这一节课我们介绍了用向量和矩阵运算来改变几何图形的形状、大小和位置。其中，向量的平移、旋转和缩放都属于仿射变换，而仿射变换具有 2 个性质：

变换前是直线段的，变换后依然是直线段

对两条直线段 a 和 b 应用同样的仿射变换，变换前后线段长度比例保持不变

那仿射变换中的旋转和缩放又属于线性变换，而线性变换在仿射变换性质的基础上还有 2 个额外的性质：

线性变换不改变坐标原点（因为如果 x0、y0等于零，那么 x、y 肯定等于 0）

线性变换可以叠加，多个线性变换的叠加结果就是将线性变换的矩阵依次相乘，再与向量相乘

通过齐次坐标和齐次矩阵，我们可以将平移这样的非线性仿射变换用更高维度的线性变换来表示。这么做的目的是让我们能够将仿射变换的组合简化为矩阵乘法运算。

到这里，数学基础篇的内容我们就学完了。在这一篇的开头，我们说了要总结出一个通用的基础数学绘图体系，这样才不至于陷入细节里。所以啊，我总结了一个简单的知识脑图，把我们在数学篇里讲过的数学知识汇总到了一起，它肯定不会是一个非常完整的数学绘图体系，但是对我们之后的学习来说，已经足够用了。

最后呢，我还想再啰嗦几句。图形学作为可视化的基础，是一门很深的学问。它牵涉的数学内容非常多，包括线性代数、几何、微积分和概率统计等等。那这门课里我们所介绍的数学知识，其实还都只是一些入门知识。

那如果你对图形学本身很感兴趣，想要深入学习它在其他领域，比如游戏、视频、AR/VR 等领域的应用，这里我推荐你一些深入学习的资料。

3Blue1Brown 的数学和图形学基础课程 讲得深入浅出，是非常棒的入门教程。

《Fundamentals of Computer Graphics》这本书是图形学入门的经典教材。

小试牛刀

在实现粒子动画的时候，我们让 translateMatrix * rotateMatrix * scaleMatrix，这三个矩阵按这样的顺序相乘。那如果我们颠倒它们的相乘次序，把 roateMatrix 放到 translateMatrix 前面，或者把 scaleMatrix 放到 translateMatrix 前面，会产生什么样的结果呢？为什么呢？你可以思考一下，然后从 GitHub 上 fork 代码，动手试一试。

我们知道，CSS 的 transform 除了 translate、rotate 和 scale 变换以外，还有 skew 变换。skew 变换是一种沿着轴向的扭曲变换，它也属于一种线性变换，它的变换矩阵是：

你可以使用这个矩阵，给我们的粒子动画加上随机的扭曲效果吗？

因为齐次坐标和齐次矩阵的概念，可以从二维一直推广到 N 维，而且 CSS 的 transform 还支持 3D 变换。那你可以用齐次矩阵的原理对 CSS 属性的 3D 变换应用 matrix3d，实现出有趣的 3D 变换效果吗？（💡小提示：要支持 3 维的齐次坐标，需要 4 维齐次矩阵）？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

[1]粒子动画的完整代码

[2]矩阵运算数学库的完整代码

推荐阅读

[1]WebGL 的 uniform 变量设置官方文档

[2]3Blue1Brown 的数学和图形学基础课程

[3]图形学入门经典教材《Fundamentals of Computer Graphics》

10 | 图形系统如何表示颜色？

你好，我是月影。从这一节课开始，我们进入一个全新的模块，开始学习视觉基础。

在可视化领域中，图形的形状和颜色信息非常重要，它们都可以用来表达数据。我们利用基本的数学方法可以绘制出各种各样的图形，通过仿射变换还能改变图形的形状、大小和位置。但关于图形的颜色，虽然在前面的课程中，我们也使用片元着色器给图形设置了不同的颜色，可这只是颜色的基本用法，Web 图形系统对颜色的支持是非常强大的。

所以这一节课，我们就来系统地学习一下，Web 图形系统中表示颜色的基本方法。我会讲四种基本的颜色表示法，分别是 RGB 和 RGBA 颜色表示法、HSL 和 HSV 颜色表示法、CIE Lab 和 CIE Lch 颜色表示法以及 Cubehelix 色盘。

不过，因为颜色表示实际上是一门非常复杂的学问，与我们自己的视觉感知以及心理学都有很大的关系，所以这节课我只会重点讲解它们的应用，不会去细说其中复杂的算法实现和规则细节。但我也会在课后给出一些拓展阅读的链接，如果你有兴趣，可以利用它们深入来学。

RGB 和 RGBA 颜色

作为前端工程师，你一定对 RGB 和 RGBA 颜色比较熟悉。在 Web 开发中，我们首选的颜色表示法就是 RGB 和 RGBA。那我们就先来说说它的应用。

1. RGB 和 RGBA 的颜色表示法

我们在 CSS 样式中看到的形式如 #RRGGBB 的颜色代码，就是 RGB 颜色的十六进制表示法，其中 RR、GG、BB 分别是两位十六进制数字，表示红、绿、蓝三色通道的色阶。色阶可以表示某个通道的强弱。

因为 RGB(A) 颜色用两位十六进制数来表示每一个通道的色阶，所以每个通道一共有 256 阶，取值是 0 到 255。RGB 的三个通道色阶的组合，理论上一共能表示 224 也就是一共 16777216 种不同的颜色。因此，RGB 颜色是将人眼可见的颜色表示为红、绿、蓝三原色不同色阶的混合。我们可以用一个三维立方体，把 RGB 能表示的所有颜色形象地描述出来。效果如下图：

RGB的所有颜色

那 RGB 能表示人眼所能见到的所有颜色吗？事实上，RGB 色值只能表示这其中的一个区域。如下图所示，灰色区域是人眼所能见到的全部颜色，中间的三角形是 RGB 能表示的所有颜色，你可以明显地看出它们的对比。

人眼看到的颜色vsRGB能表示的颜色

尽管 RGB 色值不能表示人眼可见的全部颜色，但它可以 表示的颜色也已经足够丰富了。一般的显示器、彩色打印机、扫描仪等都支持它。

在浏览器中，CSS 一般有两种表示 RGB 颜色值的方式：一种是我们前面说的 #RRGGBB 表示方式，另一种是直接用 rgb(red, green, blue) 表示颜色，这里的“red、green、blue”是十进制数值。RGB 颜色值的表示方式，你应该比较熟悉，我就不多说了。

好，理解了 RGB 之后，我们就很容易理解 RGBA 了。它其实就是在 RGB 的基础上增加了一个 Alpha 通道，也就是透明度。一些新版本的浏览器，可以用 #RRGGBBAA 的形式来表示 RGBA 色值，但是较早期的浏览器，只支持 rgba(red, green, blue, alpha) 这种形式来表示色值（注意：这里的 alpha 是一个从 0 到 1 的数）。所以，在实际使用的时候，我们要注意这一点。

WebGL 的 shader 默认支持 RGBA。因为在 WebGL 的 shader 中，我们是使用一个四维向量来表示颜色的，向量的 r、g、b、a 分量分别表示红色、绿色、蓝色和 alpha 通道。不过和 CSS 的颜色表示稍有不同的是，WebGL 采用归一化的浮点数值，也就是说，WebGL 的颜色分量 r、g、b、a 的数值都是 0 到 1 之间的浮点数。

2. RGB 颜色表示法的局限性

RGB 和 RGBA 的颜色表示法非常简单，但使用起来也有局限性（因为 RGB 和 RGBA 本质上其实非常相似，只不过后者比前者多了一个透明度通道。方便起见，我们后面就用 RGB 来代表 RGB 和 RGBA 了）。

因为对一个 RGB 颜色来说，我们只能大致直观地判断出它偏向于红色、绿色还是蓝色，或者在颜色立方体的大致位置。所以，在对比两个 RGB 颜色的时候，我们只能通过对比它们在 RGB 立方体中的相对距离，来判断它们的颜色差异。除此之外，我们几乎就得不到其他任何有用的信息了。

也就是说，当要选择一组颜色给图表使用时，我们并不知道要以什么样的规则来配置颜色，才能让不同数据对应的图形之间的对比尽可能鲜明。因此，RGB 颜色对用户其实并不友好。

这么说可能还是比较抽象，我们来看一个简单的例子。这里，我们在画布上显示 3 组颜色不同的圆，每组各 5 个，用来表示重要程度不同的信息。现在我们给这些圆以随机的 RGB 颜色，代码如下：

import {Vec3} from '../common/lib/math/vec3.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function randomRGB() {
  return new Vec3(
    0.5 * Math.random(),
    0.5 * Math.random(),
    0.5 * Math.random(),
  );
}

ctx.translate(256, 256);
ctx.scale(1, -1);

for(let i = 0; i < 3; i++) {
  const colorVector = randomRGB();
  for(let j = 0; j < 5; j++) {
    const c = colorVector.clone().scale(0.5 + 0.25 * j);
    ctx.fillStyle = `rgb(${Math.floor(c[0] * 256)}, ${Math.floor(c[1] * 256)}, ${Math.floor(c[2] * 256)})`;
    ctx.beginPath();
    ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
    ctx.fill();
  }
}

通过执行上面的代码，我们生成随机的三维向量，然后将它转成 RGB 颜色。为了保证对比，我们在每一组的 5 个圆中，依次用 0.5、0.75、1.0、1.25 和 1.5 的比率乘上我们随机生成的 RGB 数值。这样，一组圆就能呈现不同的亮度了。总体上颜色是越左边的越暗，越右边的越亮。得到的效果如下：

但是，这么做有两个缺点：首先，因为这个例子里的 RGB 颜色是随机产生的，所以行与行之间的颜色差别可能很大，也可能很小，我们无法保证具体的颜色差别大小；其次，因为无法控制随机生成的颜色本身的亮度，所以这样生成的一组圆的颜色有可能都很亮或者都很暗。比如，下图中另一组随机生成的圆，除了第一行外，后面两行的颜色都很暗，区分度太差。

因此，在需要动态构建视觉颜色效果的时候，我们很少直接选用 RGB 色值，而是使用其他的颜色表示形式。这其中，比较常用的就是 HSL 和 HSV 颜色表示形式。

HSL 和 HSV 颜色

与 RGB 颜色以色阶表示颜色不同，HSL 和 HSV 用色相（Hue）、饱和度（Saturation）和亮度（Lightness）或明度（Value）来表示颜色。其中，Hue 是角度，取值范围是 0 到 360 度，饱和度和亮度 / 明度的值都是从 0 到 100%。

虽然 HSL 和 HSV 在表示方法上有一些区别，但它们能达到的效果比较接近。所以就目前来说，我们并不需要深入理解它们之间的区别，只要学会 HSL 和 HSV 通用的颜色表示方法就可以了。

1. HSL 和 HSV 的颜色表示方法

HSL 和 HSV 是怎么表示颜色的呢？实际上，我们可以把 HSL 和 HSV 颜色理解为，是将 RGB 颜色的立方体从直角坐标系投影到极坐标的圆柱上，所以它的色值和 RGB 色值是一一对应的。

HSL和HSV的产生原理

从上图中，你可以发现，它们之间色值的互转算法比较复杂。不过好在，CSS 和 Canvas2D 都可以直接支持 HSL 颜色，只有 WebGL 需要做转换。所以，如果你有兴趣深入了解这个转换算法，可以去看一下我课后给出的推荐阅读。那在这里，你只需要记住我下面给出的这一段 RGB 和 HSV 的转换代码就可以了，后续课程中我们会用到它。

vec3 rgb2hsv(vec3 c){
  vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
  vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
  vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
  float d = q.x - min(q.w, q.y);
  float e = 1.0e-10;
  return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

vec3 hsv2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

好，记住了转换代码之后。下面，我们直接用 HSL 颜色改写前面绘制三排圆的例子。这里，我们只要把代码稍微做一些调整。

function randomColor() {
  return new Vec3(
    0.5 * Math.random(), // 初始色相随机取0~0.5之间的值
    0.7,  // 初始饱和度0.7
    0.45,  // 初始亮度0.45
  );
}

ctx.translate(256, 256);
ctx.scale(1, -1);

const [h, s, l] = randomColor();
for(let i = 0; i < 3; i++) {
  const p = (i * 0.25 + h) % 1;
  for(let j = 0; j < 5; j++) {
    const d = j - 2;
    ctx.fillStyle = `hsl(${Math.floor(p * 360)}, ${Math.floor((0.15 * d + s) * 100)}%, ${Math.floor((0.12 * d + l) * 100)}%)`;
    ctx.beginPath();
    ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
    ctx.fill();
  }
}

如上面代码所示，我们生成随机的 HSL 颜色，主要是随机色相 H，然后我们将 H 值的角度拉开，就能保证三组圆彼此之间的颜色差异比较大。

接着，我们增大每一列圆的饱和度和亮度，这样每一行圆的亮度和饱和度就都不同了。但要注意的是，我们要同时增大亮度和饱和度。因为根据 HSL 的规则，亮度越高，颜色越接近白色，只有同时提升饱和度，才能确保圆的颜色不会太浅。

2. HSL 和 HSV 的局限性

不过，从上面的例子中你也可以看出来，即使我们可以均匀地修改每组颜色的亮度和饱和度，但这样修改之后，有的颜色看起来和其他的颜色差距明显，有的颜色还是没那么明显。这是为什么呢？这里我先卖个关子，我们先来做一个简单的实验。

for(let i = 0; i < 20; i++) {
  ctx.fillStyle = `hsl(${Math.floor(i * 15)}, 50%, 50%)`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, 60, 10, 0, Math.PI * 2);
  ctx.fill();
}

for(let i = 0; i < 20; i++) {
  ctx.fillStyle = `hsl(${Math.floor((i % 2 ? 60 : 210) + 3 * i)}, 50%, 50%)`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2);
  ctx.fill();
}

如上面代码所示，我们绘制两排不同的圆，让第一排每个圆的色相间隔都是 15，再让第二排圆的颜色在色相 60 和 210 附近两两交错。然后，我们让这两排圆的饱和度和亮度都是 50%，最终生成的效果如下：

先看第一排圆你会发现，虽然它们的色相相差都是 15，但是相互之间颜色变化并不是均匀的，尤其是中间几个绿色圆的颜色比较接近。接着我们再看第二排圆，虽然这些圆的亮度都是 50%，但是蓝色和紫色的圆看起来就是不如偏绿偏黄的圆亮。这都是由于人眼对不同频率的光的敏感度不同造成的。

因此，HSL 依然不是最完美的颜色方法，我们还需要建立一套针对人类知觉的标准，这个标准在描述颜色的时候要尽可能地满足以下 2 个原则：

人眼看到的色差 = 颜色向量间的欧氏距离

相同的亮度，能让人感觉亮度相同

于是，一个针对人类感觉的颜色描述方式就产生了，它就是 CIE Lab。

CIE Lab 和 CIE Lch 颜色

CIE Lab 颜色空间简称 Lab，它其实就是一种符合人类感觉的色彩空间，它用 L 表示亮度，a 和 b 表示颜色对立度。RGB 值也可以 Lab 转换，但是转换规则比较复杂，你可以通过wikepadia.org来进一步了解它的基本原理。

CIE Lab 比较特殊的一点是，目前还没有能支持 CIE Lab 的图形系统，但是css-color level4规范已经给出了 Lab 颜色值的定义。

lab() = lab( <percentage> <number> <number> [ / <alpha-value> ]? )

而且，一些 JavaScript 库也已经可以直接处理 Lab 颜色空间了，如d3-color。下面，我们通过一个代码例子来详细讲讲，d3.lab 是怎么处理 Lab 颜色的。如下面代码所示，我们使用 d3.lab 来定义 Lab 色彩。这个例子与 HSL 的例子一样，也是显示两排圆形。这里，我们让第一排相邻圆形之间的 lab 色值的欧氏空间距离相同，第二排相邻圆形之间的亮度按 5 阶的方式递增。

/* global d3 */
for(let i = 0; i < 20; i++) {
  const c = d3.lab(30, i * 15 - 150, i * 15 - 150).rgb();
  ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, 60, 10, 0, Math.PI * 2);
  ctx.fill();
}

for(let i = 0; i < 20; i++) {
  const c = d3.lab(i * 5, 80, 80).rgb();
  ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2);
  ctx.fill();
}

代码最终的运行效果如下：

你会发现，在以 CIELab 方式呈现的色彩变化中，我们设置的数值和人眼感知的一致性比较强。

而 CIE Lch 和 CIE Lab 的对应方式类似于 RGB 和 HSL 和 HSV 的对应方式，也是将坐标从立方体的直角坐标系变换为圆柱体的极坐标系，这里就不再多说了。CIE Lch 和 CIE Lab 表示颜色的技术还比较新，所以目前我们也不会接触很多，但是因为它能呈现的色彩更贴近人眼的感知，所以我相信它会发展得很快。作为技术人，这些新技术，我们也要持续关注。

Cubehelix 色盘

最后，我们再来说一种特殊的颜色表示法，Cubehelix 色盘（立方螺旋色盘）。简单来说，它的原理就是在 RGB 的立方中构建一段螺旋线，让色相随着亮度增加螺旋变换。如下图所示：

Cubehelix色盘的原理

我们还是直接来看它的应用。接下来，我会直接用 NPM 上的cubehelix模块写一个颜色随着长度变化的柱状图，你可以通过它来看看 Cubehelix 是怎么应用的。效果如下：

它的实现代码也非常简单，我来简单说一下思路。

首先，我们直接使用 cubehelix 函数创建一个 color 映射。cubehelix 函数是一个高阶函数，它的返回值是一个色盘映射函数。这个返回函数的参数范围是 0 到 1，当它从小到大依次改变的时候，不仅颜色会依次改变，亮度也会依次增强。然后，我们用正弦函数来模拟数据的周期性变化，通过 color§获取当前的颜色值，再把颜色值赋给 ctx.fillStyle，颜色就能显示出来了。最后，我们用 rect 将柱状图画出来，用 requestAnimationFrame 实现动画就可以了 。

import {cubehelix} from 'cubehelix';

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

ctx.translate(0, 256);
ctx.scale(1, -1);

const color = cubehelix(); // 构造cubehelix色盘颜色映射函数
const T = 2000;

function update(t) {
  const p = 0.5 + 0.5 * Math.sin(t / T);
  ctx.clearRect(0, -256, 512, 512);
  const {r, g, b} = color(p);
  ctx.fillStyle = `rgb(${255 * r},${255 * g},${255 * b})`;
  ctx.beginPath();
  ctx.rect(20, -20, 480 * p, 40);
  ctx.fill();
  window.ctx = ctx;
  requestAnimationFrame(update);
}

update(0);

到这里，我们关于颜色表示的讨论就告一段落了。这 4 种颜色方式的具体应用你应该已经掌握了，那我再来说说在实际工作中，它们的具体使用场景，这样你就能记得更深刻了。

在可视化应用里，一般有两种使用颜色的方式：第一种，整个项目的 UI 配色全部由 UI 设计师设计好，提供给可视化工程师使用。那在这种情况下，设计师设计的颜色是多少就是多少，开发者使用任何格式的颜色都行。第二种方式就是根据数据情况由前端动态地生成颜色值。当然不会是整个项目都由开发者完全自由选择，而一般是由设计师定下视觉基调和一些主色，开发者根据主色和数据来生成对应的颜色。

在一般的图表呈现项目中，第一种方式使用较多。而在一些数据比较复杂的项目中，我们经常会使用第二种方式。尤其是当我们希望连续变化的数据能够呈现连续的颜色变换时，设计师就很难用预先指定的有限的颜色来表达了。这时候，我们就需要使用其他的方式，比如，HLS、CIELab 或者 Cubehelix 色盘，我们会把它们结合数据变量来动态生成颜色值。

要点总结

这一节课，我们系统地学习了 Web 图形系统表示颜色的方法。它们可以分为 2 大类，分别是 RGB、HSL 和 HSV、CIELab 和 CIELch 等颜色空间的表示方法，以及 Cubehelix 色盘的表示方法。

首先，RGB 用三原色的色阶来表示颜色，是最基础的颜色表示法，但是它对用户不够友好。而 HSL 和 HSV 是用色相、饱和度、亮度（明度）来表示颜色，对开发者比较友好，但是它的数值变换与人眼感知并不完全相符。

CIELab 和 CIELch 与 Cubehelix 色盘，这两种颜色表示法还比较新，在实际工作中使用得不是很多。其中，CIELab 和 CIELch 是与人眼感知相符的色彩空间表示法，已经被纳入 css-color level4 规范中。虽然还没有被浏览器支持，但是一些如 d3-color 这样的 JavaScript 库可以直接处理 Lab 颜色空间。而如果我们要呈现颜色随数据动态改变的效果，那 Cubehelix 色盘就是一种非常更合适的选择了。

最后，我还想再啰嗦几句。在可视化中，我们会使用图形的大小、高低、宽窄、颜色和形状这些常见信息来反映数据。一般来说，我们会使用一种叫做二维强化的技巧，来叠加两个维度的信息，从而加强可视化的视觉呈现效果。

比如，柱状图的高低表示了数据的多少，但是如果这个数据非常重要，那么我们在给柱状图设置不同高低的同时，再加上颜色的变化，就能让这个柱状图更具视觉冲击力。这也是我们必须要学会熟练运用颜色的原因。

所以，颜色的使用在可视化呈现中非常重要，在之后的课程中，我们还会继续深入探讨颜色的用法。

小试牛刀

我在课程中给出了 hsv 和 rgb 互转的 glsl 代码。你能尝试用 WebGL 画两个圆，让它们的角度对应具体的 HUE 色相值，让其中一个圆的半径对应饱和度 S，另一个圆的半径对应明度 V，将 HSV 色盘绘制出来吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

本节课示例代码完整版

推荐阅读

颜色也是可视化非常重要的内容，所以这节课的知识点比较多，参考资料也很多。如果你有兴趣深入研究，我建议你一定要认真看看我给的这些资料。

[1] CSS Color Module Level 4

[2] RGB color model

[3] HSL 和 HSV 色彩空间

[4] 色彩空间中的 HSL、HSV、HSB 的区别

[5] 用 JavaScript 实现 RGB-HSL-HSB 相互转换的方法

[6] Lab 色彩空间维基百科

[7] Cubehelix 颜色表算法

[8] Dave Green’s `cubehelix’ colour scheme

[9] d3-color 官方文档

11｜图案生成：如何生成重复图案、分形图案以及随机效果？

你好，我是月影。

图案生成是可视化中非常重要的基础。有多重要呢？我们知道，可视化中的几何图形是用来表达数据的，那图案就是用来修饰这些几何图形，强化视觉效果的，所以图案一般是指几何图形上的花纹。这些花纹有的简单，有的复杂，有的规律明显，有的看上去比较随机。也正是因为图案可以如此的不同，它们才能更好地增强视觉效果。

这一节课，我们就来聊一聊图案生成的基本原理和方法论。不过，因为可视化中的图案非常多，所以今天我们主要来讲三种最常用的，分别是重复图案、分形图案和随机图案。

首先，我们来看重复图案。

如何绘制大批量重复图案

在可视化应用中，我们经常会使用重复图案。比如说，我们在显示图表的时候，经常会给背景加上一层网格，这样可以辅助用户阅读和理解图表数据。

带有网格背景的画布

那像网格这样经典的重复图案，我们应该怎样绘制它呢？这些网格看起来像是由一条一条线段组成的，是不是利用绘制线段的方式，比如我们之前学过的 Canvas2D 的绘图指令来绘制就可以了？如果你是这么想的，就把问题想得太简单了。

举个例子，如果我们将网格绘制在 Canvas2D 画布上，那网格的线条就会很多，这也就意味着我们要用大量的绘图指令来绘制。这个时候，一旦 Canvas2D 的画面改变了，我们就需要重绘全部的网格，这会大大消耗系统的性能。而且，如果将大量的时间都浪费在绘制这种重复图案上，那我们实现的代码性能可能就会很差。

那我们该怎么办呢？你可能会想到准备两个 Canvas2D 画布，一个用来绘制网格，另一个用来绘制其他会变化的图形。能想到这个办法还是不错的，说明你动了脑筋，它确实解决了图案重绘的问题。不过，我们第一次绘图的开销仍然存在。因此，我们的解决思路不能局限在使用 Canvas2D 的绘图指令上。

1. 使用 background-image 来绘制重复图案

我们有更巧妙的办法来“绘制”这种网格图案，那就是使用 CSS 的 background-image 属性。代码如下：

canvas {
  background-image: linear-gradient(to right, transparent 90%, #ccc 0),
    linear-gradient(to bottom, transparent 90%, #ccc 0);
  background-size: 8px 8px, 8px 8px;
}

以防你对 CSS 的 linear-gradient 属性还不太熟悉，我这里简单解释一下它。CSS 的 linear-gradient 属性可以定义线性渐变，在这个例子里，to right 表示颜色过渡是从左到右的，其中 0% 到 90% 的区域是透明的，90% 到 100% 的区域是 #ccc 颜色。另外，在 linear-gradient 中定义颜色过渡的时候，如果后一个过渡颜色的区域值和前面相同，我们可以把它简单写为 0。

因为浏览器将渐变属性视为图片，所以我们可以将渐变设置在任何可以接受图片的 CSS 属性上。在这里，我们就可以把渐变设置在 background-image 上，也就是作为背景色来使用。

如上面的代码所示，我们一共给 background-image 设置了两个 linear-gradient，一个是横向的（to right），一个是纵向的（to bottom）。因为 css 的 background-repeat 默认值是 repeat，所以我们给背景设置一下 background-size。这样，我们利用浏览器自己的 background-repeat 机制，就可以实现我们想要的网格背景了。

网格图案效果

总结来说，这种利用了 CSS 属性设置重复网格背景的技巧，在一般情况下能够满足我们的需要，但也会有一些限制。首先，因为它设置的是 Canvas 元素的背景，所以它和直接绘制在画布上的其他图形就处于不同的层，我们也就没法将它覆盖在这些图形上了。其次，当我们用坐标变换来缩放或移动元素时，作为元素背景的网格是不会随着缩放或移动而改变的。

2. 使用 Shader 来绘制重复图案

那如果是用 WebGL 来渲染的话，我们还有更简单的做法，就是利用 GPU 并行计算的特点，使用着色器来绘制背景网格这样的重复图案。

这里，我直接给出了顶点着色器和片元着色器中的代码，你可以看看。

//顶点着色器:

attribute vec2 a_vertexPosition;
attribute vec2 uv;
varying vec2 vUv;

void main() {
  gl_PointSize = 1.0;
  vUv = uv;
  gl_Position = vec4(a_vertexPosition, 1, 1);

//片元着色器:

#ifdef GL_ES
precision mediump float;
#endif
varying vec2 vUv;
uniform float rows;

void main() {
  vec2 st = fract(vUv * rows);
  float d1 = step(st.x, 0.9);
  float d2 = step(0.1, st.y);
  gl_FragColor.rgb = mix(vec3(0.8), vec3(1.0), d1 * d2);
  gl_FragColor.a = 1.0;
}

那这两段 Shader 代码的具体行为是什么呢？你可以先自己想一想，这里我先卖个关子，一会儿再详细解释，我们先来看看 WebGL 绘制重复图案的过程。

我们知道，直接用 WebGL 来绘图比较繁琐，所以从这一节课开始，我们不采用原生的底层 WebGL 绘图了，而是采用一个基础库gl-renderer。gl-renderer 在 WebGL 底层的基础上进行了一些简单的封装，以便于我们将重点放在提供几何数据、设置变量和编写 Shader 上，不用因为创建 buffer 等细节而分心。

gl-renderer 的使用方法十分简单，基本上和第 4 节课 WebGL 三角形的过程一致，一共分为五步，唯一的区别是 gl-renderer 对每一步的代码进行了封装。我把这五步都列出来了，我们一起来看看。

步骤一和步骤二分别是创建 Renderer 对象和创建并启用 WebGL 程序，过程非常简单，你直接看我给出的代码就可以理解了。

//第一步:
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

//第二步:
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

步骤三和步骤四是最核心的两个步骤，我来重点说说。

步骤三是设置 uniform 变量。这里，我们设置了一个 rows 变量，表示每一行显示多少个网格。然后我们会在片元着色器中使用它。

renderer.uniforms.rows = 64;

步骤四是将顶点数据送入缓冲区。

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

在上面的代码中，我们一共设置了三个数据。首先，我们设置了 positions 也就是顶点。这里我们一共设置了四个顶点，这四个顶点坐标正好覆盖了整个 Canvas 画布。接着是 uv，也就是纹理坐标。它和纹理设置有关，不过你先不用理解什么是纹理设置，只要知道这个坐标系的左下角为 0,0，右上角为 1,1 就可以了。

顶点坐标和uv坐标

第三个是 cells，顶点索引。我们知道，WebGL 只能渲染经过三角剖分之后的多边形。那利用 cells: [(0, 1, 2), (2, 0, 3)]，我们就能将这个矩形画布剖分成两个三角形，这两个三角形的顶点下标分别是 (0, 1, 2) 和 (2, 0, 3)。

最后，我们将顶点送入缓冲区后，执行 renderer.render() 渲染，网格就被渲染出来了。

接下来，我们重点看一下片元着色器中的代码，来理解一下渲染过程。

void main() {
  vec2 st = fract(vUv * rows);
  float d1 = step(st.x, 0.9);
  float d2 = step(0.1, st.y);
  gl_FragColor.rgb = mix(vec3(0.8), vec3(1.0), d1 * d2);
  gl_FragColor.a = 1.0;
}

首先，我们要获得重复的 rows 行 rows 列的值 st。这里我们要用到一个函数 fract，它在 Shader 中非常常用，可以用来获取一个数的小数部分。当一个数从 0~1 周期性变化的时候， 我们只要将它乘以整数 N，然后再用 fract 取小数，就能得到 N 个周期的数值。

y = fract(x) 在整数区间内周期重复示意图

所以，这里我们用 vUv 也就是由顶点着色器传来的 uv 属性（纹理坐标）乘上 rows 值，然后用 fract 取小数部分，就能得到 st 了。

接着，我们处理 st 的 x 和 y。因为 WebGL 中的片元着色器线性插值，所以现在它们默认是线性变化的，而我们要的是阶梯变化。那要实现阶梯变化，我们可以使用 step 函数，step 函数是 Shader 中另一个很常用的函数，它就是一个阶梯函数。它的原理是：当 step(a, b) 中的 b < a 时，返回 0；当 b >= a 时，返回 1。

Step函数

因此，d1 和 d2 分别有 2 种取值情况。

最后，我们要根据 d1 * d2 的值，决定背景网格使用哪个颜色来绘制。要实现这个目的，我们就要使用到第三个函数 mix。mix 是线性插值函数，mix(a, b, c) 表示根据 c 是 0 或 1，返回 a 或者 b。

比如在上面的代码中，当 st.x 小于 0.9 且 st.y 大于 0.1，也就是 d1 * d2 等于 1 的时候，mix(vec3(0.8), vec3(1.0), d1 * d2) 的结果是 vec3(1.0)，也就是白色。否则就是 vec3(0.8)，也就是灰色。

最后，因为 rows 决定网格重复的次数，所以最终的效果和 rows 的取值有关。为了让你有更直观的感受，我把 row 分别取 1、4、16、32、64 时的效果都绘制出来了，你可以看看。

rows为1、4、16、32、64的效果

这就是我们用 Shader 实现重复图案的完整过程。它的优势在于，不管我们给 rows 取值多少，图案都是一次绘制出来的，并不会因为 rows 增加而消耗性能。所以，使用 Shader 绘制重复图案，不管绘制多么细腻，图案重复多少次，绘制消耗的时间几乎是常量，不会遇到性能瓶颈。

如何绘制分形图案

说完了重复图案，我们再来说分形。它不仅是自然界中存在的一种自然现象，也是一种优美的数学模型。通俗点来说，一个分形图案可以划分成无数个部分，而每个部分的形状又都和这个图案整体具有相似性。所以，典型的分形效果具有局部与整体的自相似性以及无限细节（分形可以无限放大），能产生令人震撼的视觉效果。

自然界中的分形：罗马花椰菜

实际上，分形在实践中偏向于视觉和 UI 设计。虽然它在实际的可视化项目中不太常用，但总能够起到画龙点睛的作用。所以，了解分形在视觉呈现中的实现技巧还是很有必要的。下面，我们就来详细讲讲分形是怎么实现的。

首先，我们来认识一下分形公式，Mandelbrot Set，也叫曼德勃罗特集。它是由美国数学家曼徳勃罗特教授发现的迭代公式构成的分形集合。这个公式中 Zn和 Zn+1是复数，C 是一个实数常量。

这个迭代公式使用起来非常简单，只要我们给定一个初始值，它就能产生许多有趣的图案。接下来，我们就一起来看一个有趣的例子。

首先我们实现一个片元着色器，代码如下：

#ifdef GL_ES
precision mediump float;
#endif
varying vec2 vUv;
uniform vec2 center;
uniform float scale;

vec2 f(vec2 z, vec2 c) {
  return mat2(z, -z.y, z.x) * z + c;
}

void main() {
    vec2 uv = vUv;
    vec2 c = center + 4.0 * (uv - vec2(0.5)) / scale;
    vec2 z = vec2(0.0);

    bool escaped = false;
    int j;
    for (int i = 0; i < 65536; i++) {
      if(i > iterations) break;
      j = i;
      z = f(z, c);
      if (length(z) > 2.0) {
        escaped = true;
        break;
      }
    }

    gl_FragColor.rgb = escaped ? vec3(float(j)) / float(iterations) : vec3(0.0);
    gl_FragColor.a = 1.0;
}

我们设置了初始的 z 和 c，然后执行迭代。理论上曼德勃罗特集应该是无限迭代的，但是我们肯定不能让它无限循环，所以我们要给一个足够精度的最大迭代次数，比如 65536。在迭代过程中，如果 z 的模大于 2，那它就结束计算，否则就继续迭代，直到达到循环次数。

我们把 (0, 0) 设置为图案中心点，放大系数初始设为 1，即原始大小，然后开始渲染，代码如下：

const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.uniforms.center = [0, 0];
renderer.uniforms.scale = 1;
renderer.uniforms.iterations = 256;

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

renderer.render();

画布上最终的渲染结果

这个图案本身似乎没有什么特别的效果，我们可以修改一下 Shader 中的代码，改变渲染颜色的规则，根据迭代次数和迭代深度的比值来渲染不同的颜色，然后将它局部放大，就能得到非常有趣的图案了。

如何给图案增加随机效果

那分形图案为什么这么吸引人呢？如果你多看几个，就会发现，它们的无限细节里同时拥有重复和随机这两个规律。那对于其他非分形的图案，如果也想让它变得吸引人，我们其实可以给它们增加随机效果。

不知道，你还记得我们开篇词中的那个抽奖程序吗？实际上它就是一个随机效果的应用。

产生随机色块的抽奖程序

要想实现类似这样的随机效果，在 Shader 中，我们可以使用伪随机函数。下面，我以一个常用的伪随机函数为例，来讲讲随机效果是怎么生成的。代码如下：

float random (vec2 st) {
    return fract(sin(dot(st.xy,
                         vec2(12.9898,78.233)))*
        43758.5453123);
}

这个伪随机函数的原理是，取正弦函数偏后部的小数部分的值来模拟随机。如果我们传入一个确定的 st 值，它就会返回一个符合随机分布的确定的 float 值。

我们可以测试一下这个伪随机函数，代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

float random (vec2 st) {
    return fract(sin(dot(st.xy,
                        vec2(12.9898,78.233)))*
        43758.5453123);
}

void main() {
    gl_FragColor.rgb = vec3(random(vUv));
    gl_FragColor.a = 1.0;
}

它的执行结果是一片噪点，效果如下图所示。

这些噪点显然不能满足我们想要的随机效果，因为它们只有一个像素，而且太小了。所以下一步，我们可以用 floor 取整函数，来生成随机的色块。

  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;
  float random (vec2 st) {
      return fract(sin(dot(st.xy,
                          vec2(12.9898,78.233)))*
          43758.5453123);
  }

  void main() {
      vec2 st = vUv * 10.0;
      gl_FragColor.rgb = vec3(random(floor(st)));
      gl_FragColor.a = 1.0;
  }

floor 函数和 JavaScript 的 Math.floor 一样，都是向下取浮点数的整数部分，不过，glsl 的 floor 可以直接对向量使用。我们通过 floor(st) 实际上取到了 0,0 到 9,9，一共 10 行 *10 列 =100 个方块。然后我们通过 random 函数给每一个方块随机一个颜色，最终实现的结果如下：

此外，我们还可以结合随机和动态效果。具体的方法就是传入一个代表时间的 uTime 变量，实际代码和最终效果如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

uniform float uTime;

float random (vec2 st) {
    return fract(sin(dot(st.xy,
                        vec2(12.9898,78.233)))*
        43758.5453123);
}

void main() {
    vec2 st = vUv * vec2(100.0, 50.0);

    st.x -= (1.0 + 10.0 * random(vec2(floor(st.y)))) * uTime;

    vec2 ipos = floor(st);  // integer
    vec2 fpos = fract(st);  // fraction

    vec3 color = vec3(step(random(ipos), 0.7));
    color *= step(0.2,fpos.y);

    gl_FragColor.rgb = color;
    gl_FragColor.a = 1.0;
}

除此之外，我们用 Shader 来实现网格类的效果也特别方便。比如，下面我们就在 Shader 中用 smoothstep 函数生成可以随机旋转方向的线段，从而生成一个迷宫。

#ifdef GL_ES
precision mediump float;
#endif

#define PI 3.14159265358979323846

varying vec2 vUv;
uniform vec2 u_resolution;
uniform int rows;

float random (in vec2 _st) {
    return fract(sin(dot(_st.xy,
                        vec2(12.9898,78.233)))*
        43758.5453123);
}

vec2 truchetPattern(in vec2 _st, in float _index){
    _index = fract(((_index-0.5)*2.0));
    if (_index > 0.75) {
        _st = vec2(1.0) - _st;
    } else if (_index > 0.5) {
        _st = vec2(1.0-_st.x,_st.y);
    } else if (_index > 0.25) {
        _st = 1.0-vec2(1.0-_st.x,_st.y);
    }
    return _st;
}

void main() {
    vec2 st = vUv * float(rows);
    vec2 ipos = floor(st);  // integer
    vec2 fpos = fract(st);  // fraction

    vec2 tile = truchetPattern(fpos, random( ipos ));
    float color = 0.0;

    color = smoothstep(tile.x-0.3,tile.x,tile.y)-
            smoothstep(tile.x,tile.x+0.3,tile.y);

    gl_FragColor = vec4(vec3(color),1.0);
}

要点总结

今天，我们讲了可视化中三种常用图案的生成原理。

第一种，批量重复图案。一般来说，在绘制批量重复图案的时候，我们可以采用 2 种方案。首先是使用 CSS 的 background-image 属性，利用 backgroud-repeat 快速重复绘制。其次，我们可以使用片元着色器，利用 GPU 的并行渲染的特点来绘制。

第二种，分形图案。绘制分形图案有一个可以直接的公式，曼德勃罗特集。我们可以使用它来绘制分形图案。

第三种是在重复图案上增加随机性，我们可以在片元着色器中使用伪随机函数，来给重复图案实现随机效果。

虽然我们说几何图形是用来承载数据信息，图案是来强化视觉效果的，但实际上，它们也并没有绝对的界限，有时候我们也可以将图案与数据信息一起管理。比如说，在上面那个动态效果的例子中，我们可以调整动态参数，让图形的运动速度或者黑白块的分布和数据量或者信息内容联系起来。这会大大强化可视化的视觉效果，从而加深用户对信息的理解。

在这一节课，我们讲了大量关于 WebGL 的片元着色器的知识。这是因为，片元着色器是最适合生成和绘制这些图案的技术，但这也并不意味着用其他图形系统，比如 SVG 或者 Canvas 就没法很好地生成并绘制这些图案了。

实际上，它们的基本原理是相同的，所以用 SVG 或 Canvas 同样可以绘制这些图案。只不过，因为 SVG 和 Canvas 渲染不能深入控制 GPU 底层，所以就没法做到像 WebGL 这样并行高效地渲染这些图案。那如果在选择 SVG 和 Canvas 的可视化应用中，需要绘制大量的这些图案，就必然会导致性能瓶颈，这也是为什么我们一定要了解和掌握 WebGL 技术，只有这样，我们才能真正掌握绘制极有视觉冲击力的复杂图案的能力。

最后，我还要啰嗦几句，如果你对片元着色器应用还不是很熟悉，对上面的代码还有疑问或者不是很理解，那也没有关系，你可以花一点时间，仔细研究一下GitHub 仓库的源代码。要记住，动手实践永远是我们最好的学习方式，没有之一。

另外，在接下来的课程里，我们还会大量使用片元着色器创建更多有趣、炫酷的视觉效果。所以，我也建议你去从头看看这份关于片元着色器的学习资料，The Book of Shaders，相信你会非常有收获。

小试牛刀

在前面的例子里，我们实现了一个 10*10 的灰色块方阵，这里我们使用的是灰度颜色，你能够渲染出彩色方块吗？你可以尝试将随机数映射成 HSV 坐标中的 H，然后绘制出不同的彩色方阵。

在实现抽奖程序的时候，我们在 Shader 中使用的是伪随机函数 random。那如果要实现真正的随机数，我们该怎么做呢？如果我们希望实现的迷宫图案，在我们每次刷新网页的时候都不相同，这个功能你可以实现吗？你可以 fork GitHub 仓库的代码，然后把伪随机迷宫图案修改成真正随机迷宫图案，然后把你的代码和实际效果分享出来。

我们知道，使用 background-image 的弊端是，当我们用坐标变换来缩放或移动图形的时候，作为元素背景的网格是不会随着缩放或移动而改变的。但使用 Shader，我们就能够避免这个问题了。

不过，我们在课程中没有给出缩放和移动图形的例子。你能够扩展我给出的例子，实现图案随着图形的缩放和移动变化的效果吗（这里，我再给你一个小提示，你可以使用顶点着色器和仿射变换矩阵来实现）？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程示例代码

推荐阅读

[1]基于 WebGL 底层简单封装的基础库 [gl-renderer]的官方文档 (https://github.com/akira-cn/gl-renderer) ，它可以大大简化 WebGL 代码的书写难度

[2]很棒的学习片元着色器的教程 The Book of Shaders .

12 | 如何使用滤镜函数实现美颜效果？

你好，我是月影。

通过前面的课程，我们初步了解了浏览器的图形系统，也学会了使用基本的数学和几何方法来生成和处理图像，还能用简单的图形组合来构成复杂的图案。从这一节课开始，我们进入一个新的模块，开始学习像素处理。

在可视化领域里，我们常常需要处理大规模的数据，比如，需要呈现数万甚至数十万条信息在空间中的分布情况。如果我们用几何绘制的方式将这些信息一一绘制出来，性能可能就会很差。

这时，我们就可以将这些数据简化为像素点进行处理。这种处理图像的新思路就叫做像素化。在可视化应用中，图片像素化处理是一个很重要手段，它能够在我们将原始数据信息转换成图形后，进一步处理图形的细节，突出我们想要表达的信息，还能让视觉呈现更有冲击力。

因为像素化的内容比较复杂，能做的事情也非常多，所以我们会用五节课的时间来讨论。今天是第一节课，我们先来看看图片像素化的基本思路和方法，体会如何用像素化来处理照片，从而达到“美颜”的效果。

如何理解像素化？

首先，我们来理解两个基础的概念。第一个是像素化。所谓像素化，就是把一个图像看成是由一组像素点组合而成的。每个像素点负责描述图像上的一个点，并且带有这个点的基本绘图信息。那对于一张 800 像素宽、600 像素高的图片来说，整张图一共就有 48 万个像素点。

这么多的像素点是怎么存储的呢？Canvas2D 以 4 个通道来存放每个像素点的颜色信息，每个通道是 8 个比特位，也就是 0~255 的十进制数值，4 个通道对应 RGBA 颜色的四个值。后面，我们会用 RGBA 通道来分别代表它们。

知道了什么是像素化，那像素处理又是怎么一回事呢？像素处理实际上就是我们为了达到特定的视觉效果，用程序来处理图像上每个像素点。像素处理的应用非常广泛，能实现的效果也非常多。下面，我会列举几个常用的效果，希望你通过它们能理解像素处理的一般方法和思路。

应用一：实现灰度化图片

在可视化中，当我们要给用户强调某些信息的时候，一般会将我们不想强调的信息区域置成灰度状态。这样，用户就能够快速抓住我们想要表达的重点了。这个过程就是灰度化图片。简单来说就是将一张彩色图片变为灰白色图片。具体的实现思路是，我们先将该图片的每个像素点的 R、G、B 通道的值进行加权平均，然后将这个值作为每个像素点新的 R、G、B 通道值，具体公式如下：

其中 R、G、B 是原图片中的 R、G、B 通道的色值，V 是加权平均色值，a、b、c 是加权系数，满足 (a + b + c) = 1。

好了，灰度化的原理你已经知道了，下面我们通过一个具体的例子来演示一下实际操作的过程。首先，我们写一段简单的 JavaScript 代码，通过这段代码把一张图片给加载并绘制到 Canvas 上，代码如下：

 <canvas id="paper" width="0" height="0"></canvas>
  <script>
    function loadImage(src) {
      const img = new Image();
      img.crossOrigin = 'anonymous';
      return new Promise((resolve) => {
        img.onload = () => {
          resolve(img);
        };
        img.src = src;
      });
    }

    const canvas = document.getElementById('paper');
    const context = canvas.getContext('2d');

    (async function () {
      // 异步加载图片
      const img = await loadImage('https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg');

      const {width, height} = img;
      // 将图片绘制到 canvas
      canvas.width = width;
      canvas.height = height;
      context.drawImage(img, 0, 0);
    }());
  </script>

这段代码开始创建一个 image 元素，然后通过 image 元素的 src 属性异步加载图片，加载完成后，通过 canvas 的 2d 上下文对象的 drawImage 方法，将图片元素绘制到 canvas 上。这张图片如下图所示：

接下来，我们要获取每个像素点的 R、G、B 值。具体的操作就是通过 canvas 的 2d 上下文，获取图片剪裁区的数据 imgData。那什么是 imgData 呢？imgData 是我们在像素处理中经常用到的对象，它是一个 ImageData 对象，它有 3 个属性，分别是 width、height 和 data。其中 width 表示剪裁区的宽度属性，height 表示剪裁区的高度属性，data 用来存储图片的全部像素信息。

宽、高属性我就不用多说了，我来重点说说 data 是怎么保存图片全部像素信息的。首先，图片的全部像素信息会以类型数组（Uint8ClampedArray）的形式保存在 ImageData 对象的 data 属性里，而类型数组的每 4 个元素组成一个像素的信息，这四个元素依次表示该像素的 RGBA 四通道的值，所以它的数据结构如下：

data[0] // 第1行第1列的红色通道值
data[1] // 第1行第1列的绿色通道值
data[2] // 第1行第1列的蓝色通道值
data[3] // 第1行第1列的Alpha通道值
data[4] // 第1行第2列的红色通道值
data[5] // 第1行第2列的绿色通道值
...

结合这个结构，我们可以得出 data 属性的类型数组的总长度：width * height * 4。这是因为图片一共是width * height 个像素点，每个像素点有 4 个通道，所以总长度是像素点的 4 倍。

知道了数组长度以后，接着，我们就可以遍历 data 数组，读取每个像素的 RGBA 四通道的值，将原本的 RGBA 值都用一个加权平均值 0.2126 r + 0.7152 g + 0.0722 * b 替代了，代码如下：

  for(let i = 0; i < width * height * 4; i += 4) {
    const r = data[i],
      g = data[i + 1],
      b = data[i + 2],
      a = data[i + 3];
    // 对RGB通道进行加权平均
    const v = 0.2126 * r + 0.7152 * g + 0.0722 * b;
    data[i] = v;
    data[i + 1] = v;
    data[i + 2] = v;
    data[i + 3] = a;
  }

这里你可能会觉得奇怪，我们为什么用 0.2126、0.7152 和 0.0722 这三个权重，而不是都用算术平均值 1/3 呢？这是因为，人的视觉对 R、G、B 三色通道的敏感度是不一样的，对绿色敏感度高，所以加权值高，对蓝色敏感度低，所以加权值低。

最后，我们将处理好的数据写回到 Canvas 中去。这样，我们就得到了一张经过灰度化后的图片。

灰度化后的图片

灰度化图片的过程非常简单，我们一起来总结一下。首先，我们加载一张图片将它绘制到 canvas，接着我们通过 getImageData 获取 imageData 信息，再通过 imageData.data 遍历图像上的所有像素点，对每个像素点的 RGBA 值进行加权平均处理，然后将处理好的信息回写到 canvas 中去。

我把灰度化图片的过程总结了一张流程图，你也可以参考它来理解。

重构代码以扩展其他效果

实际上，灰度化只是像素颜色处理中一个最简单的应用，除此以外，我们还可以对像素颜色做其他变换，比如增强或减弱某个通道的色值，改变颜色的亮度、对比度、饱和度、色相等等。

那为了方便讲解，也为了更好地复用代码实现其他的功能，我先重构一下上面的代码，将我们最关注的循环处理每个像素的颜色信息这一步单独剥离出来，再把其他步骤都分解并抽象成通用的模块以便于实现其他效果的时候引入。

重构代码的实现思路是，先创建一个 lib/utils.js 文件，然后把加载图片的函数 loadImage，获取 imageData 对象的函数 getImageData，以及遍历 imageData 中的类型数组的函数 traverse，都添加到 lib/utils.js 文件中。代码如下：

// lib/utls.js

// 异步加载图片
export function loadImage(src) {
  const img = new Image();
  img.crossOrigin = 'anonymous';
  return new Promise((resolve) => {
    img.onload = () => {
      resolve(img);
    };
    img.src = src;
  });
}

const imageDataContext = new WeakMap();
// 获得图片的 imageData 数据
export function getImageData(img, rect = [0, 0, img.width, img.height]) {
  let context;
  if(imageDataContext.has(img)) context = imageDataContext.get(img);
  else {
    const canvas = new OffscreenCanvas(img.width, img.height);
    context = canvas.getContext('2d');
    context.drawImage(img, 0, 0);
    imageDataContext.set(img, context);
  }
  return context.getImageData(...rect);
}

// 循环遍历 imageData 数据
export function traverse(imageData, pass) {
  const {width, height, data} = imageData;
  for(let i = 0; i < width * height * 4; i += 4) {
    const [r, g, b, a] = pass({
      r: data[i] / 255,
      g: data[i + 1] / 255,
      b: data[i + 2] / 255,
      a: data[i + 3] / 255,
      index: i,
      width,
      height,
      x: ((i / 4) % width) / width,
      y: Math.floor(i / 4 / width) / height});
    data.set([r, g, b, a].map(v => Math.round(v * 255)), i);
  }
  return imageData;
}

我们这样做了之后，像素处理的应用代码就可以得到简化。简化后的代码如下：

  import {loadImage, getImageData, traverse} from './lib/util.js';

  const canvas = document.getElementById('paper');
  const context = canvas.getContext('2d');

  (async function () {
    // 异步加载图片
    const img = await loadImage('assets/girl1.jpg');
    // 获取图片的 imageData 数据对象
    const imageData = getImageData(img);
    // 遍历 imageData 数据对象
    traverse(imageData, ({r, g, b, a}) => { // 对每个像素进行灰度化处理
      const v = 0.2126 * r + 0.7152 * g + 0.0722 * b;
      return [v, v, v, a];
    });
    // 更新canvas内容
    canvas.width = imageData.width;
    canvas.height = imageData.height;
    context.putImageData(imageData, 0, 0);
  }());

这样做的好处是，traverse 函数会自动遍历图片的每个像素点，把获得的像素信息传给参数中的回调函数处理。这样，我们就只关注 traverse 函数里面的处理过程就可以了。

应用二：使用像素矩阵通用地改变像素颜色

在灰度化图片的例子中，我们用加权平均的计算公式来替换图片的 RGBA 的值。这本质上其实是利用线性方程组改变了图片中每一个像素的 RGB 通道的原色值，将每个通道的色值映射为一个新色值。

除了加权平均的计算公式以外，我们还可以用其他的线性方程组来实现各种不同的像素变换效果。比如说，要想实现改变图片的亮度，我们可以将 R、G、B 通道的值都乘以一个常量 p，公式如下：

这里的 p 是一个常量，如果它小于 1，那么 R、G、B 值就会变小，图片就会变暗，也就更接近于黑色了。相反，如果 p 大于 1，图片就会变亮，更接近白色。这样一来，我们用不同的公式就可以将像素的颜色处理成我们所期望的结果了。

但如果你想要实现不同的颜色变换，就必须要使用不同的方程组，这会让我们使用起来非常麻烦。那你肯定想问了，有没有一种方式，可以更通用地实现更多的颜色变换效果呢？当然是有的，我们可以引入一个颜色矩阵，它能够处理几乎所有的颜色变换类滤镜。

我们创建一个 4*5 颜色矩阵，让它的第一行决定红色通道，第二行决定绿色通道，第三行决定蓝色通道，第四行决定 Alpha 通道。

那如果要改变一个像素的颜色效果，我们只需要将该矩阵与像素的颜色向量相乘就可以了。

这样一来，灰度化图片的处理过程，就可以描述成如下的颜色矩阵：

function grayscale(p = 1) {
  const r = 0.2126 * p;
  const g = 0.7152 * p;
  const b = 0.0722 * p;

  return [
    r + 1 - p, g, b, 0, 0,
    r, g + 1 - p, b, 0, 0,
    r, g, b + 1 - p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

注意，这里我们引入了一个参数 p，它是一个 0~1 的值，表示灰度化的程度，1 是完全灰度化，0 是完全不灰度，也就是保持原始色彩。这样一来，我们通过调节 p 的值就可以改变图片灰度化的程度。因此这个灰度化矩阵，比前面直接用灰度化公式更加通用。

因为 p 的取值范围是 0~1，所以 p 的取值可以分成三种情况。下面，我们一起来分析一下。

第一种，p 等于 0，这个时候，r、g、b 的值也都是 0，所以返回的矩阵就退化成单位矩阵，代码如下。这样一来，新色值和原色值就完全相同了。

[
    1, 0, 0, 0, 0,
    0, 1, 0, 0, 0,
    0, 0, 1, 0, 0,
    0, 0, 0, 1, 0
]

第二种情况当 p 等于 1 的时候，这个矩阵就正好对应我们前面的灰度化公式。

 [
    r, g, b, 0, 0,
    r, g, b, 0, 0,
    r, g, b, 0, 0,
    0, 0, 0, 1, 0,
  ]

第三种取值情况，当 p 处于 0~1 之间的时候，颜色矩阵的值就在完全灰度的矩阵和单位矩阵之间线性变化。这样我们就实现了可调节的灰度化颜色矩阵。

但是，光有颜色矩阵还不行，要想实现不同的颜色变化，根据前面的公式，我们还得让旧的色值与颜色矩阵相乘，把新的色值计算出来。

为了方便处理，我们可以增加处理颜色矩阵的模块。让它包含两个函数，一个是处理颜色矩阵的矩阵乘法运算 multiply 函数，另一个是将 RGBA 颜色通道组成的向量与颜色矩阵相乘，得到新色值的 transformColor 函数。

// lib/color-matrix.js
// 将 color 通过颜色矩阵映射成新的色值返回
export function transformColor(color, ...matrix) {
  // 颜色向量与矩阵相乘
  ... 省略的代码
}
// 将颜色矩阵相乘
export function multiply(a, b) {
  // 颜色矩阵相乘
  ...省略的代码
}

那你可能想问，为什么我们这里不仅提供了处理色值映射的 transformColor，还提供了一个矩阵乘法的 multiply 方法呢？

这是因为根据矩阵运算的性质，我们可以将多次颜色变换的过程，简化为将相应的颜色矩阵相乘，然后用最终的那个矩阵对颜色值进行映射。具体的过程你可以看看我给出的流程图。

这样，灰度化图片的实现部分就可以写成如下代码：

   ...省略代码...

    traverse(imageData, ({r, g, b, a}) => {
      return transformColor([r, g, b, a], grayscale(1));
    });

    ...省略代码...

这里的 grayscale 函数返回了实现灰度化的颜色矩阵，而要实现其他颜色变换效果，我们可以定义其他函数返回其他的颜色矩阵。这种返回颜色矩阵的函数，我们一般称为颜色滤镜函数。

抽象出了颜色滤镜函数之后，我们处理颜色代码的过程可以规范成如下图所示的过程：

我们还可以增加其他的颜色滤镜函数，比如：

function channel({r = 1, g = 1, b = 1}) {
  return [
    r, 0, 0, 0, 0,
    0, g, 0, 0, 0,
    0, 0, b, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

这个 channel 滤镜函数可以过滤或增强某个颜色通道。

// 增强红色通道，减弱绿色通道
traverse(imageData, ({r, g, b, a}) => {
  return transformColor([r, g, b, a], channel({r: 1.5, g: 0.75}));
});

举个例子，当我们调用 channel({r: 1.5, g: 0.75}) 的时候，红色通道的值被映射为原来的 1.5 倍，绿色通道的值则被映射为 0.75 倍。这样得到的图片就显得比原来要红。

在处理图片时，我们还会用到有一些常用的颜色滤镜，比如，可以修改图片的亮度（Brightness）、饱和度（Saturate）、对比度（Constrast）、透明度（Opacity），还有对图片反色（Invert）和旋转色相（HueRotate）。这些滤镜函数的结构都是一样的，只是返回的矩阵不同而已。

它们的滤镜函数如下，你结合上节课我们学过的颜色理论很容易就可以理解了，我就不详细讲了。

// 改变亮度，p = 0 全暗，p > 0 且 p < 1 调暗，p = 1 原色， p > 1 调亮
function brightness(p) {
  return [
    p, 0, 0, 0, 0,
    0, p, 0, 0, 0,
    0, 0, p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

// 饱和度，与grayscale正好相反
// p = 0 完全灰度化，p = 1 原色，p > 1 增强饱和度
function saturate(p) {
  const r = 0.2126 * (1 - p);
  const g = 0.7152 * (1 - p);
  const b = 0.0722 * (1 - p);
  return [
    r + p, g, b, 0, 0,
    r, g + p, b, 0, 0,
    r, g, b + p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

// 对比度, p = 1 原色， p < 1 减弱对比度，p > 1 增强对比度
function contrast(p) {
  const d = 0.5 * (1 - p);
  return [
    p, 0, 0, 0, d,
    0, p, 0, 0, d,
    0, 0, p, 0, d,
    0, 0, 0, 1, 0,
  ];
}

// 透明度，p = 0 全透明，p = 1 原色
function opacity(p) {
  return [
    1, 0, 0, 0, 0,
    0, 1, 0, 0, 0,
    0, 0, 1, 0, 0,
    0, 0, 0, p, 0,
  ];
}

// 反色， p = 0 原色， p = 1 完全反色
function invert(p) {
  const d = 1 - 2 * p;
  return [
    d, 0, 0, 0, p,
    0, d, 0, 0, p,
    0, 0, d, 0, p,
    0, 0, 0, 1, 0,
  ]
}

// 色相旋转，将色调沿极坐标转过deg角度
function hueRotate(deg) {
  const rotation = deg / 180 * Math.PI;
  const cos = Math.cos(rotation),
    sin = Math.sin(rotation),
    lumR = 0.2126,
    lumG = 0.7152,
    lumB = 0.0722;
  return [
    lumR + cos * (1 - lumR) + sin * (-lumR), lumG + cos * (-lumG) + sin * (-lumG), lumB + cos * (-lumB) + sin * (1 - lumB), 0, 0,
    lumR + cos * (-lumR) + sin * (0.143), lumG + cos * (1 - lumG) + sin * (0.140), lumB + cos * (-lumB) + sin * (-0.283), 0, 0,
    lumR + cos * (-lumR) + sin * (-(1 - lumR)), lumG + cos * (-lumG) + sin * (lumG), lumB + cos * (1 - lumB) + sin * (lumB), 0, 0,
    0, 0, 0, 1, 0,
  ];
}

当然了，在实际工作中为了实现更多样的效果，我们经常需要叠加使用多种滤镜函数。那这些滤镜函数该如何叠加呢？根据我们前面说过的矩阵乘法的特性，其实只要将这些滤镜函数返回的滤镜矩阵先相乘，然后把得到的矩阵再与输入的 RGBA 颜色向量相乘就可以了。

我们之前封装的 transformColor 函数，就完全考虑到了应用多个滤镜函数的需求，它的参数可以接受多个矩阵，如果传给它多个矩阵，它会将每个矩阵一一进行乘法运算。

// 将 color 通过颜色矩阵映射成新的色值返回
export function transformColor(color, ...matrix) {
  ...
  matrix = matrix.reduce((m1, m2) => multiply(m1, m2));
  ...
}

所以，如果在一些需要暖色调的场景中，我们想让一张图片变得有“阳光感”，那我们可以使用叠加 channel 函数中的红色通道、brightness 函数和 saturate 函数来实现这一效果。

具体的实现代码和效果如下所示。

traverse(imageData, ({r, g, b, a}) => {
  return transformColor(
    [r, g, b, a],
    channel({r: 1.2}), // 增强红色通道
    brightness(1.2),  // 增强亮度
    saturate(1.2),  // 增强饱和度
  );
});

应用三：使用高斯模糊对照片美颜

你肯定发现了，刚才我们讲的颜色滤镜都比较简单。没错，其实它们都是一些简单滤镜。那在实际的可视化项目中，我们通常会使用颜色滤镜来增强视觉呈现的细节，而用一种相对复杂的滤镜来模糊背景，从而突出我们要呈现给用户的内容。这个复杂滤镜就叫做高斯模糊（Gaussian Blur）。

高斯模糊的原理与颜色滤镜不同，高斯模糊不是单纯根据颜色矩阵计算当前像素点的颜色值，而是会按照高斯分布的权重，对当前像素点及其周围像素点的颜色按照高斯分布的权重加权平均。这样做，我们就能让图片各像素色值与周围色值的差异减小，从而达到平滑，或者说是模糊的效果。所以，高斯模糊是一个非常重要的平滑效果滤镜（Blur Filters）。

高斯模糊的算法分两步，第一步是生成高斯分布矩阵，这个矩阵的作用是按照高斯函数提供平滑过程中参与计算的像素点的加权平均权重。代码如下：

function gaussianMatrix(radius, sigma = radius / 3) {
  const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
  const b = -1 / (2 * sigma ** 2);
  let sum = 0;
  const matrix = [];
  for(let x = -radius; x <= radius; x++) {
    const g = a * Math.exp(b * x ** 2);
    matrix.push(g);
    sum += g;
  }

  for(let i = 0, len = matrix.length; i < len; i++) {
    matrix[i] /= sum;
  }
  return {matrix, sum};
}

那高斯分布的原理是什么呢？它其实就是正态分布，简单来说就是将当前像素点的颜色值设置为附近像素点颜色值的加权平均，而距离当前像素越近的点的权重越高，权重分布满足正态分布。

因为高斯分布涉及比较专业的数学知识，所以要展开细讲会非常复杂，而且我们在实际工作中只要理解原理并且会使用相关公式就够了。因此，你要记住我这里给出的二维高斯函数公式。

二维高斯函数

这个公式其实就是上面代码中的计算式：

const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
const b = -1 / (2 * sigma ** 2);

const g = a * Math.exp(b * x ** 2);

第二步，对图片在 x 轴、y 轴两个方向上分别进行高斯运算。也就是沿着图片的宽、高方向对当前像素和它附近的像素，应用上面得出的权重矩阵中的值进行加权平均。

实现代码如下：

export function gaussianBlur(pixels, width, height, radius = 3, sigma = radius / 3) {
  const {matrix, sum} = gaussianMatrix(radius, sigma);
  // x 方向一维高斯运算
  for(let y = 0; y < height; y++) {
    for(let x = 0; x < width; x++) {
      let r = 0,
        g = 0,
        b = 0;

      for(let j = -radius; j <= radius; j++) {
        const k = x + j;
        if(k >= 0 && k < width) {
          const i = (y * width + k) * 4;
          r += pixels[i] * matrix[j + radius];
          g += pixels[i + 1] * matrix[j + radius];
          b += pixels[i + 2] * matrix[j + radius];
        }
      }
      const i = (y * width + x) * 4;
      // 除以 sum 是为了消除处于边缘的像素, 高斯运算不足的问题
      pixels[i] = r / sum;
      pixels[i + 1] = g / sum;
      pixels[i + 2] = b / sum;
    }
  }

  // y 方向一维高斯运算
  ...省略代码

  return pixels;
}

在实现了高斯模糊函数之后，我们就可以对前面例子中的图片进行高斯模糊处理了，代码如下：

  ...省略代码...

    // 获取图片的 imageData 数据对象
    const imageData = getImageData(img);
    // 对imageData应用高斯模糊
    gaussianBlur(data, width, height);

    ...省略代码...

由于高斯模糊不是处理单一像素，而是处理一个范围内的所有像素，因此我们不能采用前面的 traverse 遍历函数的方法，而是整体对图片所有像素应用高斯模糊函数。我们最终得到的效果如下图：

你会发现，图片整体都变得“模糊”了。那这种模糊效果就完全可以用来给图片“磨皮”。处理后的图片对比效果如下：

在这里，我们不仅给图片加了高斯模糊，还用灰度化、增强了饱和度、增强了对比度和亮度。这样图片上的人的皮肤就会显得更白皙。我建议你，可以试着自己动手来实现看看。

像素化与 CSS 滤镜

那在上面这几个例子中，我们都是自己通过 Canvas 的 getImageData 方法拿到像素数据，然后遍历读取或修改像素信息。实际上，如果只是按照某些特定规则改变一个图像上的所有像素，浏览器提供了更简便的方法：CSS滤镜。

前面讲过的几种图片效果，我们都可以通过 CSS 滤镜实现。比如灰度化图片可以直接使用 img 元素来，代码如下：

<img src="https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg" style="filter:grayscale(100%)">

同样美颜效果我们也可以用 CSS 滤镜来实现：

<img src="https://p0.ssl.qhimg.com/t01161037b5fe87f236.jpg"
style="filter:blur(1.5px) grayscale(0.5) saturate(1.2) contrast(1.1) brightness(1.2)">

除此以外，比较新的浏览器上还实现了原生的 Canvas 滤镜，与 CSS 滤镜相对应。CSS 滤镜和Canvas 滤镜都能实现非常丰富的滤镜效果，在处理视觉呈现上很有用。尽管 CSS 滤镜和 Canvas 滤镜都很好用，但是在实现效果上都有局限性，它们一般只能实现比较固定的视觉效果。这对于可视化来说，这并不够用。

这个时候像素处理的优势就体现出来了，用像素处理图片更灵活，因为它可以实现滤镜功能，还可以实现更加丰富的效果，包括一些非常炫酷的视觉效果，这正是可视化领域所需要的。

要点总结

这一节课我们学习了图片像素处理的基本原理。你要掌握的核心概念有滤镜函数、高斯模糊滤镜以及内置滤镜。

在像素处理的过程中，我们可以利用 Canvas 的 getImageData API 来获取图片的像素数据，然后遍历图片的每个像素点，最后用线性方程或者矩阵变换来改变图片的像素颜色。我们可以定义函数来生成矩阵变换，这些生成矩阵变换的函数就是滤镜函数。

像 grayscale 一类的函数，就是比较简单的滤镜函数了。除此以外，我们还学习了一类复杂滤镜，其中最基础的一种是高斯模糊滤镜，我们可以用它的平滑效果来给照片“美颜”。实际上，高斯模糊滤镜经常会用来做背景模糊，以突出主题内容。

而且，我们还探讨了像素化的优势，虽然浏览器提供了内置的 Canvas 滤镜和 CSS 滤镜可以实现大部分滤镜函数的功能，我们可以直接使用它们。但是，内置滤镜实现不了一些更加复杂的视觉效果，而像素化可以。不过，我也会在后面的课程中，继续和你深入讨论一些复杂的滤镜，用它们来生成复杂效果。

小试牛刀

你能利用鼠标事件和今天学过的内容，做出一个图片局部“放大器”的效果吗？具体的效果就是，在鼠标移动在图片上时，将图片以鼠标坐标为圆心，指定半径内的内容局部放大。

源码

GitHub 仓库

推荐阅读

《高斯模糊的算法》

欢迎留言和我分享你的练习过程，如果有收获，欢迎你把这节课分享给你的朋友。

13 | 如何给简单的图案添加纹理和复杂滤镜？

你好，我是月影。

上一课我们讲了两类处理像素的滤镜，分别是颜色滤镜和高斯滤镜。其中，颜色滤镜是基本的简单滤镜。因为简单滤镜里的每个像素都是独立的，所以它的处理结果不依赖于其他像素点的信息，因此应用起来也比较简单。而高斯滤镜也就是平滑效果滤镜，它是最基本的复杂滤镜。复杂滤镜的处理结果不仅与当前像素有关，还与其周围的像素点有关，所以应用起来很复杂。

当然了，颜色滤镜和高斯滤镜能够实现的视觉效果有限。如果想要实现更复杂的视觉效果，我们还需要使用更多其他的滤镜。所以这一节课，我们就来说说，怎么结合不同滤镜实现更复杂的视觉效果。

其他简单滤镜在 Canvas 中的应用

我们知道，简单滤镜的处理效果和像素点的颜色有关。其实，还有一些简单滤镜的处理效果和像素点的坐标、外部环境（比如鼠标位置、时间）有关。这些滤镜虽然也是简单滤镜，但能实现的效果可不简单。让我们来看几个有趣的例子。

第一个例子，实现图片边缘模糊的效果。

import {loadImage, getImageData, traverse} from './lib/util.js';
const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
  const img = await loadImage('assets/girl1.jpg');
  const imageData = getImageData(img);
  traverse(imageData, ({r, g, b, a, x, y}) => {
    const d = Math.hypot((x - 0.5), (y - 0.5));
    a *= 1.0 - 2 * d;
    return [r, g, b, a];
  });
  canvas.width = imageData.width;
  canvas.height = imageData.height;
  context.putImageData(imageData, 0, 0);
}());

如上面代码所示，我们可以在遍历像素点的时候计算当前像素点到图片中心点的距离，然后根据距离设置透明度，这样我们就可以实现下面这样的边缘模糊效果了。

边缘模糊效果示意图

第二个，我们可以利用像素处理实现图片融合。比如说，我们可以给一张照片加上阳光照耀的效果。具体操作就是，把下面这张透明的 PNG 图片叠加到一张照片上。

纹理

这种能叠加到其他照片上的图片，通常被称为纹理（Texture），叠加后的效果也叫做纹理效果。纹理与图片叠加的代码和效果如下：

import {loadImage, getImageData, traverse, getPixel} from './lib/util.js';
import {transformColor, brightness, saturate} from './lib/color-matrix.js';
const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
  const img = await loadImage('assets/girl1.jpg');
  const sunlight = await loadImage('assets/sunlight.png');
  const imageData = getImageData(img);
  const texture = getImageData(sunlight);
  traverse(imageData, ({r, g, b, a, index}) => {
    const texColor = getPixel(texture, index);
    return transformColor([r, g, b, a], brightness(1 + 0.7 * texColor[3]), saturate(2 - texColor[3]));
  });
  canvas.width = imageData.width;
  canvas.height = imageData.height;
  context.putImageData(imageData, 0, 0);
}());

阳光照耀效果图

另外，我们还可以选择不同的图片，来实现不同的纹理叠加效果，比如爆炸效果、水波效果等等。

爆炸效果图

纹理叠加能实现的效果非常多，所以它也是像素处理中的基础操作。不过，不管我们是用 Canvas 的 ImageData API 处理像素、应用滤镜还是纹理合成都有一个弊端，那就是我们必须循环遍历图片上的每个像素点。如果这个图片很大，比如它是 2000px 宽、2000px 高，我们就需要遍历 400 万像素！这个计算量是相当大的。

因为在前面的例子中，我们生成的都只是静态的图片效果，所以这个计算量的问题还不明显。一旦我们想要利用像素处理，制作出更酷炫的动态效果，这样的计算量注定会成为性能瓶颈。这该怎么办呢？

好在，我们还有 WebGL 这个神器。WebGL 通过运行着色器代码来完成图形的绘制和输出。其中，片元着色器负责处理像素点的颜色。那接下来，我们来说说如何用片元着色器处理像素。

片元着色器是怎么处理像素的？

如果想要在片元着色器中处理像素，我们需要先将图片的数据信息读取出来，交给 WebGL 程序来处理，这样我们就可以在着色器中处理了。

那么如何将图片数据信息读取出来呢？在 WebGL 中，我们会使用特殊的一种对象，叫做纹理对象（Texture）。我们将纹理对象作为一种特殊格式的变量，通过 uniform 传递给着色器，这样就可以在着色器中处理了。

纹理对象包括了整张图片的所有像素点的颜色信息，在着色器中，我们可以通过纹理坐标来读取对应的具体坐标处像素的颜色信息。纹理坐标是一个变量，类型是二维向量，x、y 的值从 0 到 1。在我们前面的课程里已经见过这个变量，就是我们传给顶点着色器的 uv 属性，对应片元着色器中的 vUv 变量。

因此，着色器中是可以加载纹理对象的。具体来说就是，我们先通过图片或者 Canvas 对象来创建纹理对象，然后通过 uniform 变量把它传入着色器。这样，我们再通过纹理坐标 vUv 就可以从加载的纹理对象上获取颜色信息。

1. 加载纹理

下面，我就详细说说每一步的具体操作。

首先是创建纹理对象。这个步骤比较复杂，因为设置不同的参数可以改变我们在 Shader 中对纹理取色的行为，所以其中最复杂的是参数部分。但在这里我们不需要知道太多，你先记住我在代码里给出的这几个就够了。其他的，如果之后需要用到，你再去参考相关的资料就可以了。代码如下所示：

function createTexture(gl, img) {
  // 创建纹理对象
  const texture = gl.createTexture();

  // 设置预处理函数，由于图片坐标系和WebGL坐标的Y轴是反的，这个设置可以将图片Y坐标翻转一下
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

  // 激活指定纹理单元，WebGL有多个纹理单元，因此在Shader中可以使用多个纹理
  gl.activeTexture(gl.TEXTURE0);

  // 将纹理绑定到当前上下文
  gl.bindTexture(gl.TEXTURE_2D, texture);

  // 指定纹理图像
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);

  // 设置纹理的一些参数
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
}
  // 解除纹理绑定
  gl.bindTexture(gl.TEXTURE_2D, null);

  return texture;
}

纹理创建完成之后，我们还要设置纹理。具体来说就是，通过 gl.activeTexture 将对象绑定到纹理单元，再把纹理单元编号通过 uniform 写入 shader 变量中。

function setTexture(gl, idx) {
  // 激活纹理单元
  gl.activeTexture(gl.TEXTURE0 + idx);
  // 绑定纹理
  gl.bindTexture(gl.TEXTURE_2D, texture);
  // 获取shader中纹理变量
  const loc = gl.getUniformLocation(program, 'tMap');
  // 将对应的纹理单元写入shader变量
  gl.uniform1i(loc, idx);
  // 解除纹理绑定
  gl.bindTexture(gl.TEXTURE_2D, null);
}

这样设置完成之后，我们就可以在 Shader 中使用纹理对象了。使用的代码如下：

uniform sampler2D tMap;

...

vec3 color = texture2D(tMap, vUv); // 从纹理中提取颜色，vUv是纹理坐标

总的来说，在 WebGL 中，从创建纹理、设置纹理到使用纹理的步骤非常多，使用上可以说是非常繁琐了。方便起见，这里我们可以直接使用上一节课用过的 gl-renderer 库。经过 gl-renderer 库的封装之后，我们通过 renderer.loadTexture 就可以创建并加载纹理，然后直接将纹理对象本身作为 renderer 的 uniforms 属性值即可，就不用去关注其他细节了。具体的操作代码如下：

const texture = await renderer.loadTexture(imgURL);

renderer.uniforms.tMap = texture;

知道了原理，接下来，我们就一起来动手把图片创建为纹理，然后加载到 Shader 中去使用吧。

首先，我们读取图片纹理并加载，代码如下所示。

const texture = await renderer.loadTexture('https://p1.ssl.qhimg.com/t01cca5849c98837396.jpg');
renderer.uniforms.tMap = texture;

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

renderer.render();

然后，我们直接对纹理对象取色。对应的片元着色器代码如下所示：

#ifdef GL_ES
precision highp float;
#endif

uniform sampler2D tMap;
varying vec2 vUv;

void main() {
    gl_FragColor = texture2D(tMap, vUv);
}

在片元着色器中，我们使用 texture2D 函数来获取纹理的颜色。这个函数支持两个参数，一个是纹理单元的 uniform 变量，另一个是要获取像素的坐标，这个坐标就是我们之前用过的 uv 纹理坐标。在这个片元着色器代码里，我们只是根据 vUv 坐标将纹理图片上对应的颜色取出来，其他什么也没做，所以画布上最终呈现出来的还是原始图片。

2. 实现滤镜

加载完纹理之后，我们就可以在它的基础上实现滤镜了。用 Shader 实现滤镜的方法也很简单，为了方便你理解，这次我们就只实现图片灰度化。我们可以在前面加载纹理的基础上，引入颜色矩阵，修改后的片元着色器代码如下：

#ifdef GL_ES
precision highp float;
#endif

uniform sampler2D tMap;
uniform mat4 colorMatrix;
varying vec2 vUv;

void main() {
    vec4 color = texture2D(tMap, vUv);
    gl_FragColor = colorMatrix * vec4(color.rgb, 1.0);
    gl_FragColor.a = color.a;
}

然后，你可以把这段代码和我们刚才加载纹理的代码做个比较。你会发现，刚才我们只是简单地把 color 从纹理坐标中取出，直接把它设置给 gl_FragColor。而现在，我们在设置 gl_FragColor 的时候，是先把颜色和 colorMatrix 相乘。这样其实就相当于是对颜色向量做了一个仿射变换。

对应地，我们修改一下前面的 JavaScript 代码。其中最主要的修改操作，就是通过 uniform 引入了一个 colorMatrix。修改后的代码如下：

const texture = await renderer.loadTexture('https://p1.ssl.qhimg.com/t01cca5849c98837396.jpg');
renderer.uniforms.tMap = texture;
const r = 0.2126,
  g = 0.7152,
  b = 0.0722;
renderer.uniforms.colorMatrix = [
  r, r, r, 0,
  g, g, g, 0,
  b, b, b, 0,
  0, 0, 0, 1,
];

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

renderer.render();

还记得吗？上一节课我们也实现了一个颜色矩阵，那它们有什么区别呢？区别主要有两个。

首先，上一节课的颜色矩阵是一个 45 的矩阵，但是因为 GLSL 语法在数据类型上不能直接支持 mat4（44）以上的矩阵，所以我们要计算 4*5 矩阵很不方便。而且在通常情况下，我们不经常处理颜色的 alpha 值，所以这里我就把 alpha 通道忽略了，只对 RGB 做矩阵变换，这样我们用 mat4 的齐次矩阵就够了。

其次，根据标准的矩阵与向量乘法的法则，应该是向量与矩阵的列相乘，所以我把这次传入的矩阵转置了一下，把按行排列的 rgba 换成按列排列，就得到了下面这个矩阵。

renderer.uniforms.colorMatrix = [
  r, r, r, 0,
  g, g, g, 0,
  b, b, b, 0,
  0, 0, 0, 1,
];

这样，我们就实现了与上一节课一样的图片灰度化的功能，它是使用片元着色器实现的，在性能上要远远高于 Canvas2D。

3. 实现图片的粒子化

不过，用 Shader 只处理颜色滤镜就有些大材小用了，利用 Shader 的高性能我们可以实现一些更加复杂的效果，比如，给图片实现一个粒子化的渐显效果。如下图所示：

这个视觉效果如果在 Canvas2D 中实现，需要大量的运算，非常耗费性能，几乎不太可能流畅地运行起来，但是在 WebGL 的 Shader 中就可以轻松做到。究竟是怎么做到的呢？

我们重点来看一下 Fragment Shader 的代码。

#ifdef GL_ES
precision highp float;
#endif

uniform sampler2D tMap;
uniform float uTime;
varying vec2 vUv;

float random (vec2 st) {
    return fract(sin(dot(st.xy,
                        vec2(12.9898,78.233)))*
        43758.5453123);
}

void main() {
    vec2 st = vUv * vec2(100, 55.4);
    vec2 uv = vUv + 1.0 - 2.0 * random(floor(st));
    vec4 color = texture2D(tMap, mix(uv, vUv, min(uTime, 1.0)));
    gl_FragColor.rgb = color.rgb;
    gl_FragColor.a = color.a * uTime;
}

这段代码虽然不长，但如果你还不太熟悉 Shader，可能一眼看去，很难直接了解具体的作用，不要紧，我们一步一步来看。

首先，我们使用第 11 节课学过的重复网格技巧，将图形网格化。因为原始图像的图片像素宽高是 1000px 和 554px，所以我们用 vec2 st = vUv * vec2(100, 55.4) 就可以得到 10px X 10px 大小的网格。

然后，我们再用伪随机函数 random 根据网格随机一个偏移量，因为这个偏移量是 0~1 之间的值，我们将它乘以 2 再用 1 减去它，就能得到一个范围在 -1~1 之间的随机偏移。这样我们从纹理取色的时候，不是直接从对应的纹理坐标 vUv 处取色，而是从这个随机偏移的位置取色，就能保证取出来的颜色就是一个乱序的色值。这时候，图片显示的效果是一片随机的画面：

接着，我们引入 uTime 变量，用 mix 函数对偏移后的 uv 和原始的 vUv 相对于时间变化进行插值。当初始时间为 0 的时候，取色从 uv 取；当时间超过一个周期的时候，取色从 vUv 取；当时间在中间时，取值介于 uv 和 vUv 之间。

最后，我们再把 uTime 也和透明度关联起来。这样就实现了你上面看到的粒子化的渐显效果。

当然，这个效果做得其实还比较粗糙，因为我们引入的变量比较少，在后续的课程中，我们会一步一步深入，继续实现更加惊艳的效果。在课后，你也可以试着实现其他的效果，然后把你的成果分享出来。

4. 实现图像合成

除此之外，Fragment Shader 还可以引入多纹理，让我们可以很方便地实现图像合成。比如说，对于在电影场景合成中比较常用的绿幕图片，我们就可以使用 shader 技术把它实时地合成到其他的图像上。

举个例子，假设我们有一只猫的绿幕图片：

举个例子，现在我们有一张带有猫的绿幕图片。

带有猫的绿幕图片

我们要通过 Fragment Shader 将它合成到“高尔夫”那张照片上，具体的 shader 代码如下：

#ifdef GL_ES
precision highp float;
#endif

uniform sampler2D tMap;
uniform sampler2D tCat;
varying vec2 vUv;

void main() {
    vec4 color = texture2D(tMap, vUv);
    vec2 st = vUv * 3.0 - vec2(1.2, 0.5);
    vec4 cat = texture2D(tCat, st);

    gl_FragColor.rgb = cat.rgb;
    if(cat.r < 0.5 && cat.g > 0.6) {
      gl_FragColor.rgb = color.rgb;
    }
    gl_FragColor.a = color.a;
}

如上面的代码所示，我们可以先通过 tCat 纹理获取绿幕图片。如果 RGB 通道中的 G 通道超过阈值，且 R 通道低于阈值，我们就可以接着把猫的图像从纹理中定位出来。然后经过缩放和平移变换等操作，我们就能把它放置到画面中适当的位置。

要点总结

今天，我们讨论了边缘模糊和纹理叠加这两种滤镜，并且重点学习了用 Shader 加载纹理和实现滤镜的方法。

首先，我们知道了什么是边缘模糊，边缘模糊很容易实现，只要我们在遍历像素点的时候，同时计算当前像素点到图片中心点的距离，然后根据距离设置透明度，就可以实现边缘模糊的效果。

然后， 我们重点讲了 Shader 中的纹理叠加滤镜。

要实现这个滤镜，我们要先加载纹理，获取纹理的颜色。用 Shader 加载纹理的过程比较复杂，但我们可以使用一些封装好的库，如 gl-renderer 来简化纹理的加载。那在获取纹理的颜色的时候，我们可以通过 texture2D 函数读取纹理单元对应的 uv 坐标处的像素颜色。

加载了纹理之后呢，我们就可以通过纹理结合滤镜函数来处理像素，这就是纹理滤镜的应用场景了。通过纹理滤镜，我们不仅可以实现灰度化图片，还可以图片的粒子化渐显等等更加复杂的效果

除此之外，我们还可以使用 shader 加载多个纹理图片，把它们的颜色按照不同的方式进行叠加，从而实现图像合成。图像合成虽然在可视化中使用得比较少，但它非常适合用来实现一些特殊的视觉效果。

小试牛刀

你可以完善一下片元着色器中的颜色滤镜函数，实现灰度效果以外的效果吗？

上节课，我们用 Canvas2D 实现了平滑效果滤镜，其实我们也可以用 Fragment Shader 结合纹理的形式把它实现出来，你能做到吗？

如果我们想让一个图片的某个局部呈现“马赛克”效果，该用什么滤镜？你能把它实现出来吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程示例代码

推荐阅读

Texture 的参数设置参考文档

14 | 如何使用片元着色器进行几何造型？

你好，我是月影。

在 WebGL 中，片元着色器有着非常强大的能力，它能够并行处理图片上的全部像素，让数以百万计的运算同时完成。但也正因为它是并行计算的，所以它和常规代码顺序执行或者串行执行过程并不一样。因此，在使用片元着色器实现某些功能的时候，我们要采用与常规的 JavaScript 代码不一样的思路。

到底哪里不一样呢？今天，我就通过颜色控制，以及线段、曲线、简单几何图形等的绘制，来讲讲片元着色器是怎么进行几何造型的，从而加深你对片元着色器绘图原理的理解。

首先，我们来说比较简单的颜色控制。

如何用片元着色器控制局部颜色？

我们知道，片元着色器能够用来控制像素颜色，最简单的就是把图片绘制为纯色。比如，通过下面的代码，我们就把一张图片绘制为了纯黑色。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  gl_FragColor = vec4(0, 0, 0, 1);
}

如果想让一张图片呈现不同的颜色，我们还可以根据纹理坐标值来绘制，比如，通过下面的代码，我们就可以让某个图案的颜色，从左到右由黑向白过渡。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  gl_FragColor.rgb = vec3(vUv.x);
  gl_FragColor.a = 1.0;
}

不过，这种颜色过渡还比较单一，这里我们还可以改变一下渲染方式让图形呈现的效果更复杂。比如说，我们可以使用乘法创造一个 10*10 的方格，让每个格子左上角是绿色，右下角是红色，中间是过渡色。代码和显示的效果如下所示：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec2 st = vUv * 10.0;
  gl_FragColor.rgb = vec3(fract(st), 0.0);
  gl_FragColor.a = 1.0;
}

不仅如此，我们还可以在上图的基础上继续做调整。我们可以通过 idx = floor(st) 获取网格的索引，判断网格索引除以 2 的余数（奇偶性），根据它来决定是否翻转网格内的 x、y 坐标。这样操作后的代码和图案如下所示：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec2 st = vUv * 10.0;
  vec2 idx = floor(st);
  vec2 grid = fract(st);

  vec2 t = mod(idx, 2.0);

  if(t.x == 1.0) {
    grid.x = 1.0 - grid.x;
  }
  if(t.y == 1.0) {
    grid.y = 1.0 - grid.y;
  }
  gl_FragColor.rgb = vec3(grid, 0.0);
  gl_FragColor.a = 1.0;
}

事实上，再改用不同的方式，我们还可以生成更多有趣的图案。不过，这里我们就不继续了，因为上面这些做法有点像是灵机一动的小技巧。实际上，我们缺少的并不是小技巧，而是一套统一的方法论。我们希望能够利用它，在着色器里精确地绘制出我们想要的几何图形。

如何用片元着色器绘制圆、线段和几何图形

那接下来，我们就通过几个例子，把片元着色器精确绘图的方法论给总结出来。

1. 绘制圆

首先，我们从最简单的几何图形，也就是圆开始，来说说片元着色器的绘图过程。

一般来说，我们画圆的时候是根据点坐标到圆心的距离来生成颜色的。在片元着色器中，我们可以用 distance 函数求一下 vUv 和画布中点 vec2(0.5) 的距离，然后根据这个值设置颜色。代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  float d = distance(vUv, vec2(0.5));
  gl_FragColor.rgb = d * vec3(1.0);
  gl_FragColor.a = 1.0;
}

通过这样的方法，我们最终绘制出了一个模糊的圆，效果如下：

为什么这个圆是模糊的呢？这是因为越靠近圆心，距离 d 的值越小， gl_FragColor.rgb = d * vec3(1.0); 的颜色值也就越接近于黑色。

那如果我们要实现一个更清晰的圆应该怎么做呢？这个时候，你别忘了还有 step 函数。我们用 step 函数基于 0.2 做阶梯，就能得到一个半径为 0.2 的圆。实现代码和最终效果如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  float d = distance(vUv, vec2(0.5));
  gl_FragColor.rgb = step(d, 0.2) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

不过，你会发现我们得到的这个圆的边缘很不光滑。这是因为浮点数计算的精度导致的锯齿现象。为了解决这个问题，我们用 smoothstep 代替 step。

为什么 smoothstep 代替 step 就可以得到比较光滑的圆呢？这是因为 smoothstep 和 step 类似，都是阶梯函数。但是，与 step 的值是直接跳跃的不同，smoothstep 在 step-start 和 step-end 之间有一个平滑过渡的区间。因此，用 smoothstep 绘制的圆，边缘就会有一圈颜色过渡，就能从视觉上消除锯齿。

片元着色器绘制的圆，在构建图像的粒子效果中比较常用。比如，我们可以用它来实现图片的渐显渐隐效果。下面是片元着色器中代码，以及我们最终能够实现的效果图。

#ifdef GL_ES
precision highp float;
#endif

uniform sampler2D tMap;
uniform vec2 uResolution;
uniform float uTime;
varying vec2 vUv;

float random (vec2 st) {
    return fract(sin(dot(st.xy,
                        vec2(12.9898,78.233)))*
        43758.5453123);
}

void main() {
    vec2 uv = vUv;
    uv.y *= uResolution.y / uResolution.x;
    vec2 st = uv * 100.0;
    float d = distance(fract(st), vec2(0.5));
    float p = uTime + random(floor(st));
    float shading = 0.5 + 0.5 * sin(p);
    d = smoothstep(d, d + 0.01, 1.0 * shading);
    vec4 color = texture2D(tMap, vUv);
    gl_FragColor.rgb = color.rgb * clamp(0.5, 1.3, d + 1.0 * shading);
    gl_FragColor.a = color.a;
}

2. 绘制线

利用片元着色器绘制圆的思路，就是根据点到圆心的距离来设置颜色。实际上，我们也可以用同样的原理来绘制线，只不过需要把点到点的距离换成点到直线（向量）的距离。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec3 line = vec3(1, 1, 0);
  float d = abs(cross(vec3(vUv,0), normalize(line)).z);
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

比如，我们利用上面的代码，就能在画布上画出一条斜线。

如果你还不能一眼看出上面的代码为什么能画出一条直线，说明你对于图形学的向量计算思维还没有完全适应。不过别着急，随着我们练习的增多，你会逐渐适应的。下面，我来解释一下这段代码。

这里，我们用一个三维向量 line 来定义一条直线。因为我们要绘制的是 2D 图形，所以 z 保持 0 就行，而 x 和 y 用来决定方向。

然后呢，我们求 vUv 和 line 的距离。这里我们直接用向量叉乘的性质就能求得。因为两个二维向量叉积的 z 轴分量的大小，就是这两个向量组成的平行四边形的面积，那当我们把 line 的向量归一化之后，这个值就是 vUv 到直线的距离 d 了。因为这个 d 带符号，所以我们还需要取它的绝对值。

最后，我们用这个 d 结合前面使用过的 smoothstep 来控制像素颜色，就能得到一条直线了。

3. 用鼠标控制直线

画出直线之后，我们改变 line 还可以得到不同的直线。比如，在着色器代码中，我们再添加一个 uniform 变量 uMouse，就可以根据鼠标位置来控制直线方向。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec2 uMouse;

void main() {
  vec3 line = vec3(uMouse, 0); // 用向量表示所在直线
  float d = abs(cross(vec3(vUv,0), normalize(line)).z); // 叉乘表示平行四边形面积，底边为1，得到距离
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

对应地，我们需要在 JavaScript 中将 uMouse 通过 uniforms 传入，代码如下：

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

renderer.uniforms.uMouse = [-1, -1];

canvas.addEventListener('mousemove', (e) => {
  const {x, y, width, height} = e.target.getBoundingClientRect();
  renderer.uniforms.uMouse = [
    (e.x - x) / width,
    1.0 - (e.y - y) / height,
  ];
});

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

renderer.render();

在上面的例子中，我们的直线是经过原点的。那如果我们想让直线经过任意的定点该怎么办？我们可以加一个 uniform 变量 uOrigin，来表示直线经过的固定点。代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec2 uMouse;
uniform vec2 uOrigin;

void main() {
  vec3 line = vec3(uMouse - uOrigin, 0); // 用向量表示所在直线
  float d = abs(cross(vec3(vUv - uOrigin, 0), normalize(line)).z); // 叉乘表示平行四边形面积，底边为1，得到距离
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

延续这个绘制直线的思路，我们很容易就能知道该如何绘制线段了。绘制线段与绘制直线的方法几乎一样，只不过，我们要将计算点到直线的距离修改为计算点到线段的距离。

但是因为点和线段之间有两种关系，一种是点在线段上，另一种是在线段之外。所以我们在求点到线段的距离 d 的时候，要分两种情况讨论：当点到线段的投影位于线段两个端点中间的时候，它就等于点到直线的距离；当点到线段的投影在两个端点之外的时候，它就等于这个点到最近一个端点的距离。

这么说还是比较抽象，我画了一个示意图。你会看到，C1到线段 ab 的距离就等于它到线段所在直线的距离，C2到线段 ab 的距离是它到 a 点的距离，C3到线段的距离是它到 b 点的距离。那么如何判断究竟是 C1、C2、C3中的哪一种情况呢？答案是通过 C1到线段 ab 的投影来判断。

所以，我们在原本片元着色器代码的基础上，抽象出一个 seg_distance 函数，用来返回点到线段的距离。修改后的代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec2 uMouse;
uniform vec2 uOrigin;

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

void main() {
  float d = seg_distance(vUv, uOrigin, uMouse);
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

这么修改之后，如果我们将 uOrigin 设为 vec2(0.5, 0.5)，就会得到如下效果：

4. 绘制三角形

你可能已经发现了，不管是画圆还是画线，我们使用的原理都是求点到点或者是点到线段距离。实际上，这个原理还可以扩展应用到封闭平面图形的绘制上。那我们就以三角形为例，来说说片元着色器的绘制结合图形的方法。

首先，我们要判断点是否在三角形内部。我们知道，点到三角形三条边的距离有三个，只要这三个距离的符号都相同，我们就能确定点在三角形内。

然后，我们建立三角形的距离模型。我们规定它的内部距离为负，外部距离为正，并且都选点到三条边的最小距离。代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

float line_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  return cross(p, normalize(ab)).z;
}

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
  float d1 = line_distance(st, a, b);
  float d2 = line_distance(st, b, c);
  float d3 = line_distance(st, c, a);

  if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
    return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
  }

  return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
}

void main() {
  float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

这样，我们就绘制出了一个白色的三角形。

实际上，三角形的这种画法还可以推广到任意凸多边形。比如，矩形和正多边形就可以使用同样的方式来绘制。

片元着色器绘图方法论：符号距离场渲染

现在，你应该知道这些基本的线段、圆和几何图形该怎么绘制了。那我们能不能从中总结出一套统一的方法论呢？我们发现，前面绘制的图形虽然各不相同，但是它们的绘制步骤都可以总结为以下两步。

第一步：定义距离。这里的距离，是一个人为定义的概念。在画圆的时候，它指的是点到圆心的距离；在画直线和线段的时候，它是指点到直线或某条线段的距离；在画几何图形的时候，它是指点到几何图形边的距离。

第二步：根据距离着色。首先是用 smoothstep 方法，选择某个范围的距离值，比如在画直线的时候，我们设置 smoothstep(0.0, 0.01, d)，就表示选取距离为 0.0 到 0.01 的值。然后对这个范围着色，我们就可以将图形的边界绘制出来了。

延续这个思路，我们还可以选择距离在 0.0~0.01 范围以外的点。下面，我们做一个小实验。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec3 line = vec3(1, 1, 0);
  float d = abs(cross(vec3(vUv,0), normalize(line)).z);
  gl_FragColor.rgb = (smoothstep(0.195, 0.2, d) - smoothstep(0.2, 0.205, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

我们用之前绘制直线的代码，将 gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0) 修改为 gl_FragColor.rgb = (smoothstep(0.195, 0.2, d) - smoothstep(0.2, 0.205, d)) * vec3(1.0)，我们看到输出的结果变成对称的两条直线了。

这是为什么呢？因为我们是对距离原直线 0.2 处的点进行的着色，那实际上距离 0.2 的点有两条线，所以就能绘制出两条直线了。我把它的原理画了一个示意图，你可以看看，其中红线是原直线。

利用这个思路，再加上使用乘法和 fract 函数重复绘制的原理，我们就可以绘制多条平行线了。比如通过下面的代码，我们可以绘制出均匀的平面分割线。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec3 line = vec3(1, 1, 0);
  float d = abs(cross(vec3(vUv,0), normalize(line)).z);
  d = fract(20.0 * d);
  gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

利用同样的办法，我们还可以绘制圆环或者三角环或者其他图形的环。因为原理相同，下面我就直接给你展示代码和效果了。

首先是绘制圆环的代码和效果：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  float d = distance(vUv, vec2(0.5));
  d = fract(20.0 * d);
  gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

然后是绘制三角环的代码和效果：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

float line_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  return cross(p, normalize(ab)).z;
}

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
  float d1 = line_distance(st, a, b);
  float d2 = line_distance(st, b, c);
  float d3 = line_distance(st, c, a);

  if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
    return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
  }

  return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
}

void main() {
  float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
  d = fract(20.0 * abs(d));
  gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

实际上，上面这种绘制图形和环的方式，在图形渲染中有一个专有的名称叫做符号距离场渲染（Signed Distance Fields Rendering）。它本质上就是利用空间中的距离分布来着色的。我们把上面的三角环代码换一种渲染方式，你就能看得更清楚一些了。代码如下：

void main() {
  float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
  d = fract(20.0 * abs(d));
  gl_FragColor.rgb = vec3(d);
  // gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

我们在渲染的时候，还可以把 main 函数中原来的 smoothstep 渲染方式注释掉，直接用 vec3(d) 来渲染颜色，就会得到的如下的效果。

你能看到，这里的每一环，两两之间的距离是沿着法线方向从 0 到 1 的，所以颜色从黑色过渡到白色，这就是三角环的距离场分布。相同颜色值的环线就是距离场的等距线，我们用 step 或 smoothstep 的方式将某些等距线的颜色设置为白色，其他位置颜色设置为黑色，就绘制出之前的环线效果来了。

着色器绘制几何图形的用途

讨论到这里，你一定有些疑惑，我们学习这些片元着色器的绘图方式，究竟有什么实际用途呢？实际上它的用途还是挺广泛的，在这里我想先简单举几个实际的应用案例，你可以先感受一下。

不过，在讲具体案例之前，我还想多啰嗦几句。着色器造型是着色器的一种非常基础的使用方法，甚至可以说是图形学中着色器渲染最基础的原理，就好比代数的基础是四则运算一样，它构成了 GPU 视觉渲染的基石，我们在视觉呈现中生成的各种细节特效的方法，万变不离其宗，基本上都和着色器造型有关。

所以呢，我希望你不仅仅要了解它的用途，更要彻底弄明白它的原理和思路，尤其是非常重要的符号距离场渲染技巧，一定要理解并熟练掌握。关于着色器造型的更多、更复杂的应用场景，我们在后续的课程中还会遇到。明白了这一点，我们接着来看三个简单的案例吧。

首先，我们可以用着色器造型实现图像的剪裁。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

uniform sampler2D tMap;
uniform float uTime;

...

void main() {
  vec4 color = texture2D(tMap, vUv);
  vec2 uv = vUv - vec2(0.5);
  vec2 a = vec2(-0.577, 0) - vec2(0.5);
  vec2 b = vec2(0.5, 1.866) - vec2(0.5);
  vec2 c = vec2(1.577, 0) - vec2(0.5);

  float scale = min(1.0, 0.0005 * uTime);
    float d = triangle_distance(uv, scale * a, scale * b, scale * c);
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * color.rgb;
    gl_FragColor.a = 1.0;
  }

利用上面的代码，我们对图像进行三角形剪裁，可以实现的效果如下：

其次，我们可以实现对图像的动态修饰，比如类似下面这种进度条。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

uniform sampler2D tMap;
uniform float uTime;

...

void main() {
  vec4 color = texture2D(tMap, vUv);
  vec2 uv = vUv - vec2(0.5);
  vec2 a = vec2(0, 1);
  float time = 0.0005 * uTime;

  vec2 b = vec2(sin(time), cos(time));
  float d = 0.0;

  float c0 = cross(vec3(b, 0.0), vec3(a, 0.0)).z;
  float c1 = cross(vec3(uv, 0.0), vec3(a, 0.0)).z;
  float c2 = cross(vec3(uv, 0.0), vec3(b, 0.0)).z;
  if(c0 > 0.0 && c1 > 0.0 && c2 < 0.0) {
    d = 1.0;
  }
  if(c0 < 0.0 && (c1 >= 0.0 || c2 <= 0.0)) {
    d = 1.0;
  }

  gl_FragColor.rgb = color.rgb;
  gl_FragColor.r *= mix(0.3, 1.0, d);
  gl_FragColor.a = mix(0.9, 1.0, d);
}

第三，我们还可以在一些 3D 场景中修饰几何体，比如像这样给一个球体套一个外壳，这个例子的代码我就不贴出来了，在后续 3D 课程中我们再详细来说。

要点总结

这一节课，我们学习了使用片元着色器进行几何造型的 2 种常用方法。

首先，用片元着色器可以通过控制局部颜色来绘制图案，比如根据像素坐标来控制颜色变化，然后利用重复绘制的技巧，形成有趣的图案花纹。

其次，我们可以定义并计算像素坐标的距离，然后根据距离来填充颜色，这种方法实际上叫做符号距离场渲染，是着色器造型生成图案的基础方法。通过这种方法我们可以绘制圆、直线、线段、三角形以及其他图形。

使用着色器绘制几何图形是 WebGL 常用的方式，它有许多用途，比如可以剪裁图像、显示进度条、实现外壳纹路等等，因此在可视化中有许多使用场景。

小试牛刀

这一节课我们介绍了圆、直线、线段和三角形的基本画法，其他图形也可以用 t 方法来绘制。试着用同样的思路来绘制正方形、正六角星、椭圆吧！

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课再见！

源码

本节课完整代码.

15 | 如何用极坐标系绘制有趣图案？

你好，我是月影。

在前面的课程中，我们一直是使用直角坐标系来绘图的。但在图形学中，除了直角坐标系之外，还有一种比较常用的坐标系就是极坐标系。

极坐标示意图

你对极坐标系应该也不陌生，它是一个二维坐标系。与二维直角坐标系使用 x、y 分量表示坐标不同，极坐标系使用相对极点的距离，以及与 x 轴正向的夹角来表示点的坐标，如（3，60°）。

在图形学中，极坐标的应用比较广泛，它不仅可以简化一些曲线方程，甚至有些曲线只能用极坐标来表示。不过，虽然用极坐标可以简化许多曲线方程，但最终渲染的时候，我们还是需要转换成图形系统默认支持的直角坐标才可以进行绘制。在这种情况下，我们就必须要知道直角坐标和极坐标是怎么相互转换的。两个坐标系具体转换比较简单，我们可以用两个简单的函数，toPolar 和 fromPolar 来实现，函数代码如下：

// 直角坐标影射为极坐标
function toPolar(x, y) {
  const r = Math.hypot(x, y);
  const θ= Math.atan2(y, x);
  return [r, θ];
}

// 极坐标映射为直角坐标
function fromPolar(r, θ) {
  const x = r * cos(θ);
  const y = r * sin(θ);
  return [x, y];
}

那今天，我们就通过参数方程结合极坐标，来绘制一些不太好用直角坐标系绘制的曲线，让你认识极坐标的优点，从而帮助你掌握极坐标的用法。

如何用极坐标方程绘制曲线

在第 6 节课中，为了更方便地绘制曲线，我们用 parametric.js 函数实现了一个参数方程的绘图模块，它非常方便。所以在使用极坐标方程绘制曲线的时候，我们也要用到 parametric.js 函数。不过，在使用之前，我们还要对它进行扩展，让它支持坐标映射。这样，我们就可以写出对应的坐标映射函数，从而将极坐标映射为绘图需要的直角坐标了。

具体的操作就是，给 parametric 增加一个参数 rFunc。rFunc 是一个坐标映射函数，通过它我们可以将任意坐标映射为直角坐标，修改后的代码如下：

export function parametric(sFunc, tFunc, rFunc) {
  return function (start, end, seg = 100, ...args) {
    const points = [];
    for(let i = 0; i <= seg; i++) {
      const p = i / seg;
      const t = start * (1 - p) + end * p;
      const x = sFunc(t, ...args);
      const y = tFunc(t, ...args);
      if(rFunc) {
        points.push(rFunc(x, y));
      } else {
        points.push([x, y]);
      }
    }
    return {
      draw: draw.bind(null, points),
      points,
    };
  };
}

看到这里，你可能想问，直角坐标和极坐标转换的函数，我们在一开始不是已经讲过了吗？为什么这里又要拓展一个 rFunc 参数呢？其实啊，开头我给出的函数虽然足够简单，但不够灵活，也不便于扩展。而先使用 rFunc 来抽象坐标映射，再把其他函数作为 rFunc 参数传给 parametric，是一种更通用的坐标映射方法，它属于函数式编程思想。

说到这，我再多说几句。虽然函数式设计思想不是我们这个课程的核心，但它对框架和库的设计很重要，所以，我讲它也是希望你能通过这个例子，尽可能地理解代码中的精髓，学会使用最佳的设计方法和思路去解决问题，获得更多额外的收获，而不只是去理解眼前的基本概念。

那接下来，我们用极坐标参数方程画一个半径为 200 的半圆。在这里，我们把 fromPolar 作为 rFunc 参数传给 parametric，就可以使用极坐标的参数方程来绘制图形了，代码如下所示。

const fromPolar = (r, θ) => {
  return [r * Math.cos(θ), r * Math.sin(θ)];
};

const arc = parametric(
  t => 200,
  t => t,
  fromPolar,
);

arc(0, Math.PI).draw(ctx);

此外，我们还可以添加其他的极坐标参数方程来绘制更多曲线，比如玫瑰线、心形线或者双纽线。因为这些操作都比较简单，我就直接在下面给出代码了。

const rose = parametric(
  (t, a, k) => a * Math.cos(k * t),
  t => t,
  fromPolar,
);

rose(0, Math.PI, 100, 200, 5).draw(ctx, {strokeStyle: 'blue'});

const heart = parametric(
  (t, a) => a - a * Math.sin(t),
  t => t,
  fromPolar,
);

heart(0, 2 * Math.PI, 100, 100).draw(ctx, {strokeStyle: 'red'});

const foliumRight = parametric(
  (t, a) => Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
  t => t,
  fromPolar,
);

const foliumLeft = parametric(
  (t, a) => -Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
  t => t,
  fromPolar,
);

foliumRight(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'});
foliumLeft(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'});

最终，我们能够绘制出如下的效果：

总的来说，我们看到，使用极坐标系中参数方程来绘制曲线的方法，其实和我们学过的直角坐标系中参数方程绘制曲线差不多，唯一的区别就是在具体实现的时候，我们需要额外增加一个坐标映射函数，将极坐标转为直角坐标才能完成最终的绘制。

如何使用片元着色器与极坐标系绘制图案？

在前面的例子中，我们主要还是通过参数方程来绘制曲线，用 Canvas2D 进行渲染。那如果我们使用 shader 来渲染，又该怎么使用极坐标系绘图呢？

这里，我们还是以圆为例，来看一下用 shader 渲染，再以极坐标画圆的做法。你可以先尝试自己理解下面的代码，然后再看我后面的讲解。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

vec2 polar(vec2 st) {
  return vec2(length(st), atan(st.y, st.x));
}

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  gl_FragColor.rgb = smoothstep(st.x, st.x + 0.01, 0.2) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

在上面的代码中，我们先通过坐标转换公式实现 polar 函数。这个函数作用是将直角坐标转换为极坐标，相当于课程一开始，我们用 JavaScript 写的 toPolar 函数。这里有一个细节需要注意，我们使用的是 GLSL 内置的 float atan(float, float) 方法，对应的方法是 Math.atan，而在 JavaScript 版本的 toPolar 函数中，对应的方法是 Math.atan2。

然后，我们将像素坐标转换为极坐标：st = polar(st); ，转换后的 st.x 实际上是极坐标的 r 分量，而 st.y 就是极坐标的θ分量。

我们知道，对于极坐标下过极点的圆，实际上的 r 值就是一个常量值，对应圆的半径，所以我们取 smoothstep(st.x, st.x + 0.01, 0.2)，就能得到一个半径为 0.2 的圆了。这一步，我们用的还是上节课的距离场方法。只不过，在直角坐标系下，点到圆心的距离 d 需要用 x、y 平方和的开方来计算，而在极坐标下，点的极坐标 r 值正好表示了点到圆心的距离 d，所以计算起来就比直角坐标系简单了很多。

其实，我们无论是用直角坐标还是极坐标来画图，方法都差不多。但是，一些其他的曲线用极坐标绘制会很方便。比如说，要绘制玫瑰线，我们就可以用以下代码：

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = 0.5 * cos(st.y * 3.0) - st.x;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

这样，在画布上绘制出来的结果是三瓣玫瑰线：

可能你还是会有疑问，为什么 d = 0.5 * cos(st.y * 3.0) - st.x; 绘制出的图形就是三瓣玫瑰线的图案呢？

这是因为玫瑰线的极坐标方程 r = a * cos(k * θ)，所以玫瑰线上的所有点都满足 0 = a * cos(k * θ) - r 这个方程式。如果我们再把它写成距离场的形式：d = a * cos(k * θ) - r。这个时候就有三种情况：玫瑰线上点的 d 等于 0；玫瑰线围出的图形外的点的 d 小于 0，玫瑰线围出的图形内的点的 d 大于 0。

因此，smoothstep(-0.01, 0.01, d) 能够将 d >= 0，也就是玫瑰线内的点选出来，这样也就绘制出了三瓣图形。

那玫瑰线有什么用呢？它是一种很有趣图案，我们只要修改 u_k 的值，并且保证它是正整数，就可以绘制出不同瓣数的玫瑰线图案。

uniform float u_k;

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = 0.5 * cos(st.y * u_k) - st.x;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

renderer.uniforms.u_k = 3;

setInterval(() => {
  renderer.uniforms.u_k += 2;
}, 200);

类似的图案还有花瓣线：

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

在 u_k=3 的时候，我们可以得到如下图案：

有趣的是，它和玫瑰线不一样，u_k 的取值不一定要是整数。这让它能绘制出来的图形更加丰富，比如说我们可以取 u_k=1.3，这时得到的图案就像是一个横放的苹果。

在此基础上，我们还可以再添加几个 uniform 变量，如 u_scale、u_offset 作为参数，来绘制出更多图形。代码如下：

varying vec2 vUv;
uniform float u_k;
uniform float u_scale;
uniform float u_offset;

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = u_scale * 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x + u_offset;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

当我们取 u_k=1.7，u_scale=0.5，u_offset=0.2 时，就能得到一个横置的葫芦图案。

如果我们继续修改 d 的计算方程，还能绘制出其他有趣的图形。

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = smoothstep(-0.3, 1.0, u_scale * 0.5 * cos(st.y * u_k) + u_offset) - st.x;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

比如，当继续修改 d 的计算方程时，我们可以绘制出花苞图案：

方法已经知道了，你可以在课后结合三角函数、abs、smoothstep，来尝试绘制一些更有趣的图案。如果有什么特别好玩的图案，你也可以分享出来。

极坐标系如何实现角向渐变？

除了绘制有趣的图案之外，极坐标的另一个应用是角向渐变（Conic Gradients）。那角向渐变是什么呢？如果你对 CSS 比较熟悉，一定知道角向渐变就是以图形中心为轴，顺时针地实现渐变效果。而且新的 CSS Image Values and Replaced Content 标准 level4 已经添加了角向渐变，我们可以使用它来创建一个基于极坐标的颜色渐变，代码如下：

div.conic {
  width: 150px;
  height: 150px;
  border-radius: 50%;
  background: conic-gradient(red 0%, green 45%, blue);
}

我们可以通过角向渐变创建一个颜色由角度过渡的元素。在 WebGL 中，我们可以通过极坐标用片元着色器实现类似的角向渐变效果，代码如下：

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = smoothstep(st.x, st.x + 0.01, 0.2);
  // 将角度范围转换到0到2pi之间
  if(st.y < 0.0) st.y += 6.28;
  // 计算p的值，也就是相对角度，p取值0到1
  float p = st.y / 6.28;
  if(p < 0.45) {
    // p取0到0.45时从红色线性过渡到绿色
    gl_FragColor.rgb = d * mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p /  0.45);
  } else {
    // p超过0.45从绿色过渡到蓝色
    gl_FragColor.rgb = d * mix(vec3(0, 0.5, 0), vec3(0, 0, 1.0), (p - 0.45) / (1.0 - 0.45));
  }
  gl_FragColor.a = 1.0;
}

如上面代码所示，我们将像素坐标转变为极坐标之后，st.y 就是与 x 轴的夹角。因为 polar 函数里计算的 atan(y, x) 的取值范围是 -π到π，所以我们在 st.y 小于 0 的时候，将它加上 2π，这样就能把取值范围转换到 0 到 2π了。

然后，我们根据角度换算出对应的比例对颜色进行线性插值。比如，比例在 0%~45% 之间，我们让颜色从红色过渡为绿色，那在 45% 到 100% 之间，我们让颜色从绿色过渡到蓝色。这样，我们最终就会得到如下效果：

这个效果与 CSS 角向渐变得到的基本上一致，除了 CSS 角向渐变的起始角度是与 Y 轴的夹角，而 shader 是与 X 轴的夹角以外，没有其他的不同。这样，我们就可以在 WebGL 中利用极坐标系实现与 CSS 角向渐变一致的视觉效果了。

极坐标如何绘制 HSV 色轮？

想要实现丰富的视觉效果离不开颜色，通过前面的课程，我们已经知道各种颜色的表示方法，为了更方便地调试颜色，我们可以进一步来实现色轮。什么是色轮呢？色轮可以帮助我们，把某种颜色表示法所能表示的所有颜色方便、直观地显示出来。

那在 WebGL 中，我们该怎么绘制 HSV 色轮呢？我们可以用极坐标结合 HSV 颜色来绘制它。

接下来，就让我们一起在片元着色器中实现它吧。实现的过程其实并不复杂，我们只需要将像素坐标转换为极坐标，再除以 2π，就能得到 HSV 的 H 值。然后我们用鼠标位置的 x、y 坐标来决定 S 和 V 的值，完整的片元着色器代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec2 uMouse;

vec3 hsv2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

vec2 polar(vec2 st) {
  return vec2(length(st), atan(st.y, st.x));
}

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = smoothstep(st.x, st.x + 0.01, 0.2);
  if(st.y < 0.0) st.y += 6.28;
  float p = st.y / 6.28;
  gl_FragColor.rgb = d * hsv2rgb(vec3(p, uMouse.x, uMouse.y));
  gl_FragColor.a = 1.0;
}

最终的效果如下图所示：

圆柱坐标与球坐标

最后，我还想和你说说极坐标和圆柱坐标系以及球坐标系之间的关系。我们知道极坐标系是二维坐标系，如果我们将极坐标系延 z 轴扩展，可以得到圆柱坐标系。圆柱坐标系是一种三维坐标系，可以用来绘制一些三维曲线，比如螺旋线、圆内螺旋线、费马曲线等等。

圆柱坐标系

因为极坐标系可以和直角坐标系相互转换，所以直角坐标系和圆柱坐标系也可以相互转换，公式如下：

从上面的公式中你会发现，我们只转换了 x、y 的坐标，因为它们是极坐标，而 z 的坐标因为本身就是直角坐标不用转换。因此圆柱坐标系又被称为半极坐标系。

在此基础上，我们还可以进一步将圆柱坐标系转为球坐标系。

球坐标系

同样地，圆柱坐标系也可以和球坐标系相互转换，公式如下：

球坐标系在三维图形绘制、球面定位、碰撞检测等等可视化实现时都很有用，在后续的课程中，我们会有机会用到球坐标系，在这里你需要先记住它的转换公式。

要点总结

这一节课，我们学习了一个新的坐标系统也就是极坐标系，并且理解了直角坐标系与极坐标系的相互转换。

极坐标系是使用相对极点的距离，以及与 x 轴正向的夹角来表示点的坐标。极坐标系想要转换为直角坐标系需要用到 fromPolar 函数，反过来需要用到 toPolar 函数。

那在具体使用极坐标来绘制曲线的时候，有两种渲染方式。第一种是用 Cavans 渲染，这时候，我们可以用到之前学过的 parametric 高阶函数，将极坐标参数方程和坐标映射函数 fromPolar 传入，得到绘制曲线的函数，再用它来执行绘制。这样，极坐标系就能实现直角坐标系不太好描述的曲线了，比如，玫瑰线、心形线等等。

第二种是使用 shader 渲染，一般的方法是先将像素坐标转换为极坐标，然后使用极坐标构建距离场并着色。它能实现更多复杂的图案。

除了绘图，使用极坐标还可以实现角向渐变和 HSV 色轮。角向渐变通常可以用在构建饼图，而 HSV 色轮一般用在颜色可视化和择色交互等场合里。

此外，你还需要了解圆柱坐标、球坐标与直角坐标系的相互转换。在后续课程里，我们会使用圆柱坐标或球坐标来处理三维图形，到时候它们会非常有用。

小试牛刀

用极坐标绘制小图案时，我们绘制了苹果和葫芦的图案，但它们是横置的。你可以试着修改它们，让它们的方向变为正向吗？具体怎么做呢？

在角向渐变的例子中，CSS 角向渐变是与 Y 轴的夹角，而使用着色器绘制的版本是与 X 轴的夹角。那如果要让着色器绘制版本的效果与 CSS 角向渐变效果完全一致，我们该怎么做呢？

我们已经学过了随机数、距离场以及极坐标，你是不是可以利用它们绘制出一个画布，并且呈现随机的剪纸图案，类似的效果如下所示。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课再见！

源码

parametric-shader

ploar-shader

16 | 如何使用噪声生成复杂的纹理？

你好，我是月影。

在第 11 节课中，我们使用随机技巧生成噪点、迷宫等复杂图案。它们的作用都是表达数据和增强视觉效果。要想在可视化视觉呈现中实现更加酷炫的视觉效果，我们经常需要生成能够模拟大自然的、丰富而复杂的纹理图案。

那么这节课，我们就继续来讨论，如何使用随机技巧来生成更加复杂的纹理图案。

什么是噪声？

我们先来回忆一下，随机效果是怎么生成的。在第 11 节课中，我们使用一个离散的二维伪随机函数，随机生成了一片带有噪点的图案。代码和最终效果如下：

float random (vec2 st) {
    return fract(sin(dot(st.xy,
        vec2(12.9898,78.233)))*
        43758.5453123);
}

然后，我们用取整的技巧，将这个图案局部放大，就呈现出了如下的方格状图案：

在真实的自然界中，这种离散的随机是存在的，比如鸟雀随机地鸣叫，蝉鸣随机地响起再停止，雨滴随机地落在某个位置等等。但随机和连续并存是更常见的情况，比如山脉的走向是随机的，山峰之间的高度又是连续，类似的还有天上的云朵、水流的波纹、被侵蚀的土地等等。

因此，要模拟这些真实自然的图形，我们就需要把随机和连续结合起来，这样就形成了噪声（Noise）。

如何实现噪声函数？

随机和连续究竟是怎么合成的呢？换句话说，噪声函数是怎么实现的呢？

因为随机数是离散的，那如果我们对离散的随机点进行插值，可以让每个点之间的值连续过渡。因此，我们用 smoothstep 或者用平滑的三次样条来插值，就可以形成一条连续平滑的随机曲线。

下面，我们就通过生成折线的小例子来验证一下。代码如下：

#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;

// 随机函数
float random (float x) {
  return fract(sin(x * 1243758.5453123));
}

void main() {
  vec2 st = vUv - vec2(0.5);
  st *= 10.0;
  float i = floor(st.x);
  float f = fract(st.x);

  // d直接等于随机函数返回值，这样d不连续
  float d = random(i);
  // float d = mix(random(i), random(i + 1.0), f);
  // float d = mix(random(i), random(i + 1.0), smoothstep(0.0, 1.0, f));
  // float d = mix(random(i), random(i + 1.0), f * f * (3.0 - 2.0 * f));

  gl_FragColor.rgb = (smoothstep(st.y - 0.05, st.y, d) - smoothstep(st.y, st.y + 0.05, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

首先，我们对 floor(st.x) 取随机数，取出 10 个不同的 d 值，然后把它们绘制出来，就能在画布上呈现出 10 段不连续的线段。

然后，我们用 mix(random(i), random(i + 1.0), f); 替换 random(i)（你可以将上面代码第 18 行注释掉，将第 19 行注释去掉），那么这些线段的首尾就会连起来，也就是说我们将得到一段连续的折线。

不过，我们得到的折线虽然连续，但因为这个函数在端点处不可导，所以它不平滑。因此，我们可以改用 mix(random(i), random(i + 1.0), smoothstep(0.0, 1.0, f)); 替换 random(i)（上面代码的第 20 行），或者直接采用三次多项式 mix(random(i), random(i + 1.0), f * f * (3.0 - 2.0 * f));（上面代码的第 21 行，这个三次多形式能达到和 smoothstep 一样的效果）来替换 step。这样，我们就得到一条连续并且平滑的曲线了。

这也就是我们想要的噪声函数了。

但是，这个函数是一维的，如果要使用二维的，我们还可以把它扩展到二维。这个时候，我们就必须要知道，二维噪声和一维噪声之间的区别。很明显，一维噪声是对两点进行插值的，而二维噪声需要对平面画布上方形区域的四个顶点，分别从 x、y 方向进行两次插值。

图片来源：The Book of Shaders

具体怎么做呢？我们可以把 st 与方形区域的四个顶点（对应四个向量）做插值，这样就能得到二维噪声。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

float random (vec2 st) {
    return fract(sin(dot(st.xy,
                        vec2(12.9898,78.233)))*
        43758.5453123);
}

// 二维噪声，对st与方形区域的四个顶点插值
highp float noise(vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix( mix( random( i + vec2(0.0,0.0) ),
                    random( i + vec2(1.0,0.0) ), u.x),
                mix( random( i + vec2(0.0,1.0) ),
                    random( i + vec2(1.0,1.0) ), u.x), u.y);
}

void main() {
    vec2 st = vUv * 20.0;
    gl_FragColor.rgb = vec3(noise(st));
    gl_FragColor.a = 1.0;
}

通过上面的代码，我们就可以得到下面这个看起来比较模糊的噪声图案。

模糊的噪声图案

噪声的应用

那你可能想问了，我们上面实现的一维噪声波形和二维的模糊噪声图案都比较简单，那它们到底是怎么模拟自然界中的现象，又该怎么实现有趣的视觉效果呢？

接下来，我们先结合上面得到的噪声函数，来讲 2 个简单的噪声应用，让你对它们能有更具体的认知。然后，我会在此基础上，再讲一些其他噪声函数，以及噪声能实现的更复杂视觉效果，让你对噪声有更深入的理解。

首先，我们可以结合噪声和距离场，来实现类似于水滴滚过物体表面的效果。

void main() {
    vec2 st = mix(vec2(-10, -10), vec2(10, 10), vUv);
    float d = distance(st, vec2(0));
    d *= noise(uTime + st);
    d = smoothstep(0.0, 1.0, d) - step(1.0, d);
    gl_FragColor.rgb = vec3(d);
    gl_FragColor.a = 1.0;
}

我们也可以使用不同的距离场构造方式，加上旋转噪声，构造出类似于木头的条纹。代码如下：

float lines(in vec2 pos, float b){
  float scale = 10.0;
  pos *= scale;
  return smoothstep(0.0, 0.5 + b * 0.5, abs((sin(pos.x * 3.1415) + b * 2.0)) * 0.5);
}

vec2 rotate(vec2 v0, float ang) {
  float sinA = sin(ang);
  float cosA = cos(ang);
  mat3 m = mat3(cosA, -sinA, 0, sinA, cosA, 0, 0, 0, 1);
  return (m * vec3(v0, 1.0)).xy;
}

void main() {
  vec2 st = vUv.yx * vec2(10.0, 3.0);
  st = rotate(st, noise(st));

  float d = lines(st, 0.5);

  gl_FragColor.rgb = 1.0 - vec3(d);
  gl_FragColor.a = 1.0;
}

这两个应用的实现代码非常简单，你直接看代码就能理解。我更希望的是，你能通过我给出的代码，来理解这种噪声结合距离场的实现思路。

梯度噪声

我们前面说的噪声算法，它的原理是对离散的随机值进行插值，因此它又被称为插值噪声（Value Noise）。插值噪声有一个缺点，就是它的值的梯度不均匀。最直观的表现就是，二维噪声图像有明显的“块状”特点，不够平滑。

想要解决这个问题，我们可以使用另一种噪声算法，也就是梯度噪声（Gradient Noise）。梯度噪声是对随机的二维向量来插值，而不是一维的随机数。这样我们就能够获得更加平滑的噪声效果。梯度噪声的代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

vec2 random2(vec2 st){
  st = vec2( dot(st,vec2(127.1,311.7)),
            dot(st,vec2(269.5,183.3)) );
  return -1.0 + 2.0 * fract(sin(st) * 43758.5453123);
}

// Gradient Noise by Inigo Quilez - iq/2013
// https://www.shadertoy.com/view/XdXGW8
float noise(vec2 st) {
  vec2 i = floor(st);
  vec2 f = fract(st);
  vec2 u = f * f * (3.0 - 2.0 * f);

  return mix( mix( dot( random2(i + vec2(0.0,0.0) ), f - vec2(0.0,0.0) ),
                  dot( random2(i + vec2(1.0,0.0) ), f - vec2(1.0,0.0) ), u.x),
              mix( dot( random2(i + vec2(0.0,1.0) ), f - vec2(0.0,1.0) ),
                  dot( random2(i + vec2(1.0,1.0) ), f - vec2(1.0,1.0) ), u.x), u.y);
}

void main() {
    vec2 st = vUv * 20.0;
    gl_FragColor.rgb = vec3(0.5 * noise(st) + 0.5);
    gl_FragColor.a = 1.0;
}

梯度噪声可以平滑到什么程度呢？我在下面给出了两种噪声算法生成的图像，你可以明显得看出对比。第一个图是插值噪声的效果，黑白色之间过渡不够平滑，还有明显的色块感，第二个图是梯度噪声的效果，黑白的过渡就明显平滑多了，不再呈现块状。

插值噪声与梯度噪声对比

因此，梯度噪声在二维空间中的应用更广泛，许多有趣的模拟自然界特效的视觉实现都采用了梯度噪声。你可以研究一下Shadertoy.com平台上的一些例子，其中很多模拟自然界的例子都和梯度噪声有关，我就不一一列举了。

用噪声实现云雾效果

我还想给你讲一种使用噪声来模拟云雾效果的方法。如果你看过极客时间里 winter 老师的《重学前端》，可能对这个方法有所了解，因为他在一篇加餐简单提到过。在这里，我想给你详细说说云雾效果究竟是怎么实现的。

我们可以通过改变噪声范围，然后按照不同权重来叠加的方式创造云雾效果。比如，我们可以将噪声叠加 6 次，然后让它每次叠加的时候范围扩大一倍，但是权重减半。通过这个新的噪声算法，我们就能生成云雾效果了。你也可以试试，让这个噪声配合色相变化，可以创造出非常有趣的图形，比如模拟飞机航拍效果。

//

#define OCTAVES 6
float mist(vec2 st) {
  //Initial values
  float value = 0.0;
  float amplitude = 0.5;

  // 叠加6次
  for(int i = 0; i < OCTAVES; i++) {
    // 每次范围扩大一倍，权重减半
    value += amplitude * noise(st);
    st *= 2.0;
    amplitude *= 0.5;
  }
  return value;
}

//配合色相的变化

void main() {
  vec2 st = vUv;
  st.x += 0.1 * uTime;
  gl_FragColor.rgb = hsb2rgb(vec3 (mist(st), 1.0, 1.0));
  gl_FragColor.a = 1.0;
}

使用云雾效果生成的图案，非常像是飞机航拍

Simplex Noise

接下来，我还想给你讲一种更新的噪声算法，它是 Ken Perlin 在 2001 年的 Siggraph 会议上展示的 Simplex Noise 算法。

相比于前面的噪声算法，Simplex Noise 算法有更低的计算复杂度和更少的乘法运算，并且可以用更少的计算量达到更高的维度，而且它制造出的噪声非常自然。

Simplex Noise 与插值噪声以及梯度噪声的不同之处在于，它不是对四边形进行插值，而是对三角网格进行插值。与四边形插值相比，三角网格插值需要计算的点更少了，这样自然大大降低了计算量，从而提升了渲染性能。

Simplex Noise 具体的实现思路非常精巧和复杂，其中包含的数学技巧比较高深，这里我就不详细来讲了，如果你有兴趣学习可以参考Book of Shaders 的文章来学习。

尽管 Simplex Noise 的原理很巧妙和复杂，但是在 Shader 中实现 Simplex Noise 代码并不算太复杂，你可以记住下面的代码，在需要的时候直接拿来使用。

vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }

//
// Description : GLSL 2D simplex noise function
//      Author : Ian McEwan, Ashima Arts
//  Maintainer : ijm
//     Lastmod : 20110822 (ijm)
//     License :
//  Copyright (C) 2011 Ashima Arts. All rights reserved.
//  Distributed under the MIT License. See LICENSE file.
//  https://github.com/ashima/webgl-noise
//
float noise(vec2 v) {
    // Precompute values for skewed triangular grid
    const vec4 C = vec4(0.211324865405187,
                        // (3.0-sqrt(3.0))/6.0
                        0.366025403784439,
                        // 0.5*(sqrt(3.0)-1.0)
                        -0.577350269189626,
                        // -1.0 + 2.0 * C.x
                        0.024390243902439);
                        // 1.0 / 41.0

    // First corner (x0)
    vec2 i  = floor(v + dot(v, C.yy));
    vec2 x0 = v - i + dot(i, C.xx);

    // Other two corners (x1, x2)
    vec2 i1 = vec2(0.0);
    i1 = (x0.x > x0.y)? vec2(1.0, 0.0):vec2(0.0, 1.0);
    vec2 x1 = x0.xy + C.xx - i1;
    vec2 x2 = x0.xy + C.zz;

    // Do some permutations to avoid
    // truncation effects in permutation
    i = mod289(i);
    vec3 p = permute(
            permute( i.y + vec3(0.0, i1.y, 1.0))
                + i.x + vec3(0.0, i1.x, 1.0 ));

    vec3 m = max(0.5 - vec3(
                        dot(x0,x0),
                        dot(x1,x1),
                        dot(x2,x2)
                        ), 0.0);

    m = m*m ;
    m = m*m ;

    // Gradients:
    //  41 pts uniformly over a line, mapped onto a diamond
    //  The ring size 17*17 = 289 is close to a multiple
    //      of 41 (41*7 = 287)
    vec3 x = 2.0 * fract(p * C.www) - 1.0;
    vec3 h = abs(x) - 0.5;
    vec3 ox = floor(x + 0.5);
    vec3 a0 = x - ox;

    // Normalise gradients implicitly by scaling m
    // Approximation of: m *= inversesqrt(a0*a0 + h*h);
    m *= 1.79284291400159 - 0.85373472095314 * (a0*a0+h*h);

    // Compute final noise value at P
    vec3 g = vec3(0.0);
    g.x  = a0.x  * x0.x  + h.x  * x0.y;
    g.yz = a0.yz * vec2(x1.x,x2.x) + h.yz * vec2(x1.y,x2.y);
    return 130.0 * dot(m, g);
}

void main() {
    vec2 st = vUv * 20.0;
    gl_FragColor.rgb = vec3(0.5 * noise(st) + 0.5);
    gl_FragColor.a = 1.0;
}

渲染效果如下图：

Simplex Noise 可以实现出令人惊叹的效果，在Shadertoy.com平台上经常有大神分享他们创作的神奇效果。比如，这个像某种溶洞的岩壁效果，就有一种大自然鬼斧神工的韵味在。

再比如，这种像电影大片中才有的效果，你很难想象这并不是视频，甚至不是图片，只不过是我们用数学公式在 Shader 中计算并绘制出来的图案而已。

网格噪声

最后，我们来讲讲网格噪声。前面我们已经使用过大量网格化的技术，我想你也应该比较熟悉了。那什么是网格噪声呢？它就是将噪声与网格结合使用的一种纹理生成技术。下面，让我们通过一个生成动态生物细胞的例子，来详细理解一下如何使用网格噪声。

首先，我们用网格技术将画布分为 10*10 的网格。然后，我们构建距离场。这个距离场是在每个网格中随机一个特征点，然后计算网格内到该点的距离，最后根据距离来着色。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform float uTime;

vec2 random2(vec2 st){
  st = vec2( dot(st,vec2(127.1,311.7)),
            dot(st,vec2(269.5,183.3)) );
  return fract(sin(st) * 43758.5453123);
}

void main() {
  vec2 st = vUv * 10.0;

  float d = 1.0;
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  vec2 p = random2(i_st);
  d = distance(f_st, p);
  gl_FragColor.rgb = vec3(d);
  gl_FragColor.a = 1.0;
}

通过上面的代码，我们最终能得到如下的效果：

我们可以看到，这里的每个网格是独立的，并且界限分明。那如果我们想让它们的边界过渡更圆滑该怎么办呢？我们可以在原来的代码上做改变，具体来说就是不仅计算特征点到当前网格的距离，还要计算它到周围相邻的 8 个网格的距离，然后取最小值。与其他的编程语言类似，这个可以通过 for 循环来实现：

void main() {
  vec2 st = vUv * 10.0;
  float d = 1.0;
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  for(int i = -1; i <= 1; i++) {
    for(int j = -1; j <= 1; j++) {
      vec2 neighbor = vec2(float(i), float(j));
      vec2 p = random2(i_st + neighbor);
      d = min(d, distance(f_st, neighbor + p));
    }
  }

  gl_FragColor.rgb = vec3(d);
  gl_FragColor.a = 1.0;
}

这里有一点需要注意，GLSL 语言的 for 循环限制比较多。其中，检查循环是否继续的次数必须是常量，不能是变量。所以 GLSL 中没有动态循环，而且迭代的次数必须是确定的。这里我们要检查 9 个网格，所以就用了两重循环来实现。

计算像素坐标到九个特征点的最短距离

然后我们加上 uTime，让网格动起来，另外我们把特征点也给显示出来。我们修改一下代码：

void main() {
  vec2 st = vUv * 10.0;

  float d = 1.0;
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  for(int i = -1; i <= 1; i++) {
    for(int j = -1; j <= 1; j++) {
      vec2 neighbor = vec2(float(i), float(j));
      vec2 p = random2(i_st + neighbor);
      p = 0.5 + 0.5 * sin(uTime + 6.2831 * p);
      d = min(d, distance(f_st, neighbor + p));
    }
  }

  gl_FragColor.rgb = vec3(d) + step(d, 0.03);
  gl_FragColor.a = 1.0;
}

这样，最终绘制的效果如下，它就有点像是运动的生物细胞。

网格噪声是一种目前被广泛应用的程序化纹理技术，用来生成随机网格类的视觉效果，可以用来模拟物体表面的晶格、晶体生长、细胞、微生物等等有趣的效果。

网格噪声模拟物体表面晶格，图片来源于The Book of Shaders

要点总结

总的来说，这节课我给你讲的技术，实际上是一种复杂的程序化纹理生成技术。所谓程序化纹理生成技术，就是用程序来生成物体表面的图案。我们在这些图案中引入类似于自然界中的随机性，就可以模拟出自然的、丰富多采的，以及包含真实细节的纹理图案。

这其中最有代表性的就是噪声了，噪声就是随机性与连续性结合而成的。噪声是自然界中普遍存在的自然规律。模拟噪声的基本思路是对离散的随机数进行平滑处理，对随机数进行平滑处理有不同的数学技巧，所以有插值噪声、梯度噪声、Simplex Noise 等等不同的噪声算法。它们各有特点，我们可以根据不同的情况来选择怎么使用。

这一节课的内容偏向于技巧性，要想掌握好，我建议你多动手实践。我推荐给你一个非常不错的平台，Shadertoy.com 。它是一个非常优秀的创作和分享着色器效果的平台，你可以在上面学习到很多优秀的案例，然后通过代码来理解创作者的创意和思路，巩固今天所学的知识。

小试牛刀

你能试着写出一个 Shader，来实现我在下面给出的网格噪声效果吗？欢迎你把它分享出来。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课再见！

源码

完整示例代码见GitHub 仓库

推荐阅读

[1] Shadertoy

[2] The Book of Shaders

17 | 如何使用后期处理通道增强图像效果？

你好，我是月影。

前面几节课，我们学习了利用向量和矩阵公式，来处理像素和生成纹理的技巧，但是这些技巧都有一定的局限性：每个像素是彼此独立的，不能共享信息。

为什么这么说呢？因为 GPU 是并行渲染的，所以在着色器的执行中，每个像素的着色都是同时进行的。这样一来，我们就不能获得某一个像素坐标周围坐标点的颜色信息，也不能获得要渲染图像的全局信息。

这会导致什么问题呢？如果我们要实现与周围像素点联动的效果，比如给生成的纹理添加平滑效果滤镜，就不能直接通过着色器的运算来实现了。

因此，在 WebGL 中，像这样不能直接通过着色器运算来实现的效果，我们需要使用其他的办法来实现，其中一种办法就是使用后期处理通道。所谓后期处理通道，是指将渲染出来的图像作为纹理输入给新着色器处理，是一种二次加工的手段。这么一来，虽然我们不能从当前渲染中获取周围的像素信息，却可以从纹理中获取任意 uv 坐标下的像素信息，也就相当于可以获取任意位置的像素信息了。

使用后期处理通道的一般过程是，我们先正常地将数据送入缓冲区，然后执行 WebGLProgram。只不过，在执行了 WebGLProgram 之后，我们要将输出的结果再作为纹理，送入另一个 WebGLProgram 进行处理，这个过程可以进行一次，也可以循环多次。最后，经过两次 WebGLProgram 处理之后，我们再输出结果。

后期通道处理的一般过程示意图

你可以先仔细看看这张流程总结图，加深一下印象。接下来，我会结合这个过程，说说怎么用后期处理通道，来实现 Blur 滤镜、辉光效果和烟雾效果，这样你就能理解得更深刻了。

首先，我们来实现 Blur 滤镜。

如何用后期处理通道实现 Blur 滤镜？

其实在第 11 节课中，我们已经在 Canvas2D 中实现了 Bblur 滤镜（高斯模糊的平滑效果滤镜），但 Canvas2D 实现滤镜的性能不佳，尤其是在图片较大，需要大量计算的时候。

而在 WebGL 中，我们可以通过后期处理来实现高性能的 Blur 滤镜。下面，我就以给随机三角形图案加 Blur 滤镜为例，来说说具体的操作。

首先，我们实现一个绘制随机三角形图案的着色器。代码如下：

#ifdef GL_ES
precision highp float;
#endif

float line_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  return cross(p, normalize(ab)).z;
}

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
  float d1 = line_distance(st, a, b);
  float d2 = line_distance(st, b, c);
  float d3 = line_distance(st, c, a);

  if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
    return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
  }

  return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
}

float random (vec2 st) {
  return fract(sin(dot(st.xy,
                      vec2(12.9898,78.233)))*
      43758.5453123);
}

vec3 hsb2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

varying vec2 vUv;

void main() {
  vec2 st = vUv;
  st *= 10.0;
  vec2 i_st = floor(st);
  vec2 f_st = 2.0 * fract(st) - vec2(1);
  float r = random(i_st);
  float sign = 2.0 * step(0.5, r) - 1.0;

  float d = triangle_distance(f_st, vec2(-1), vec2(1), sign * vec2(1, -1));
  gl_FragColor.rgb = (smoothstep(-0.85, -0.8, d) - smoothstep(0.0, 0.05, d)) * hsb2rgb(vec3(r + 1.2, 0.5, r));
  gl_FragColor.a = 1.0;
}

这个着色器绘制出的效果如下图：

接着就是重点了，我们要使用后期处理通道对它进行高斯模糊。

首先，我们需要准备另一个着色器：blurFragment。通过它，我们能将第一次渲染后生成的纹理 tMap 内容给显示出来。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;

void main() {
  vec4 color = texture2D(tMap, vUv);
  gl_FragColor.rgb = color.rgb;
  gl_FragColor.a = color.a;
}

然后，我们要修改 JavaScript 代码，把渲染分为两次。第一次渲染时，我们启用 program 程序，但不直接把图形输出到画布上，而是输出到一个帧缓冲对象（Frame Buffer Object）上。第二次渲染时，我们再启用 blurProgram 程序，将第一次渲染完成的纹理（fbo.texture）作为 blurFragment 的 tMap 变量，这次的输出绘制到画布上。代码如下：

...

renderer.useProgram(program);

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

const fbo = renderer.createFBO();
renderer.bindFBO(fbo);
renderer.render();
renderer.bindFBO(null);

const blurProgram = renderer.compileSync(blurFragment, vertex);
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.uniforms.tMap = fbo.texture;
renderer.render();

其中，renderer.createFBO 是创建帧缓冲对象，bindFBO 是绑定帧缓冲对象。为了方便调用，我在这里通过 gl-renderer 做了一层简单的封装。

那经过两次渲染之后，我们运行程序输出的结果和之前输出的并不会有什么区别。因为第二次渲染只不过是将第一次渲染到帧缓冲的结果原封不动地输出到画布上了。

接下来，我们修改 blurFragment 的代码，在其中添加高斯模糊的代码。代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;
uniform int axis;

void main() {
  vec4 color = texture2D(tMap, vUv);

  // 高斯矩阵的权重值
  float weight[5];
  weight[0] = 0.227027;
  weight[1] = 0.1945946;
  weight[2] = 0.1216216;
  weight[3] = 0.054054;
  weight[4] = 0.016216;

  // 每一个相邻像素的坐标间隔，这里的512可以用实际的Canvas像素宽代替
  float tex_offset = 1.0 / 512.0;

  vec3 result = color.rgb;
  result *= weight[0];
  for(int i = 1; i < 5; ++i) {
    float f = float(i);
    if(axis == 0) { // x轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
    } else { // y轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
    }
  }

  gl_FragColor.rgb = result.rgb;
  gl_FragColor.a = color.a;
}

因为高斯模糊有两个方向，x 和 y 方向，所以我们至少要执行两次渲染，一次对 x 轴，另一次对 y 轴。如果想要达到更好的效果，我们还可以执行多次渲染。

那我们就以分别对 x 轴和 y 轴执行 2 次渲染为例，修改后的 JavaScript 代码如下：

// 创建两个FBO对象交替使用
const fbo1 = renderer.createFBO();
const fbo2 = renderer.createFBO();

// 第一次，渲染原始图形
renderer.bindFBO(fbo1);
renderer.render();

// 第二次，对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();

// 第三次，对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();

// 第四次，对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();

// 第五次，对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(null);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();

在上面的代码中，我们创建了两个 FBO 对象，然后将它们交替使用。我们一共进行 5 次绘制，先对原始图片执行 1 次渲染，再进行 4 次后期处理。

这里啊，我还要告诉你一个小技巧。本来啊，在执行的这 5 次绘制中，前四次都是输出到帧缓冲对象，所以我们至少需要 4 个 FBO 对象。但是，由于我们可以交替使用 FBO 对象，也就是可以把用过的对象重复使用。因此，无论需要绘制多少次，我们都只要创建两个对象就可以，也就节约了内存。

最终，我们就能通过后期处理通道实现 Blur 滤镜，给三角形图案加上模糊的效果了。渲染结果如下：

如何用后期处理通道实现辉光效果？

在上面这个例子中，我们是对所有元素进行高斯模糊的。那除此之外，我们还可以对特定元素进行高斯模糊。在可视化和游戏开发中，就常用这种技巧来实现元素的“辉光”效果。比如，下面这张图就是用辉光效果实现的浩瀚宇宙背景。

全局辉光效果（图片来源于知乎：H光大小姐）

那类似这样的辉光效果该怎么实现呢？我们可以在前面添加高斯模糊例子的基础上进行修改。首先，我们给 blurFragment 加了一个关于亮度的滤镜，将颜色亮度大于 filter 值的三角形过滤出来添加高斯模糊。修改后的代码如下。

uniform float filter;

void main() {
  vec4 color = texture2D(tMap, vUv);
  float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
  brightness = step(filter, brightness);

  // 高斯矩阵的权重值
  float weight[5];
  weight[0] = 0.227027;
  weight[1] = 0.1945946;
  weight[2] = 0.1216216;
  weight[3] = 0.054054;
  weight[4] = 0.016216;

  // 每一个相邻像素的坐标间隔，这里的512可以用实际的Canvas像素宽代替
  float tex_offset = 1.0 / 512.0;

  vec3 result = color.rgb;
  result *= weight[0];
  for(int i = 1; i < 5; ++i) {
    float f = float(i);
    if(axis == 0) { // x轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
    } else { // y轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
    }
  }

  gl_FragColor.rgb = brightness * result.rgb;
  gl_FragColor.a = color.a;
}

然后，我们再增加一个 bloomFragment 着色器，用来做最后的效果混合。这里，我们会用到一个叫做Tone Mapping（色调映射）的方法。这个方法就比较复杂了，在这里，你只要知道它可以将对比度过大的图像色调映射到合理的范围内就可以了，其他的内容你可以在课后看一下我给出的参考链接。

这个着色器的代码如下：

#ifdef GL_ES
  precision highp float;
#endif

uniform sampler2D tMap;
uniform sampler2D tSource;

varying vec2 vUv;

void main() {
  vec3 color = texture2D(tSource, vUv).rgb;
  vec3 bloomColor = texture2D(tMap, vUv).rgb;
  color += bloomColor;
  // tone mapping
  float exposure = 2.0;
  float gamma = 1.3;
  vec3 result = vec3(1.0) - exp(-color * exposure);
  // also gamma correct while we're at it
  if(length(bloomColor) > 0.0) {
    result = pow(result, vec3(1.0 / gamma));
  }
  gl_FragColor.rgb = result;
  gl_FragColor.a = 1.0;
}

最后，我们修改 JavaScript 渲染的逻辑，添加新的后期处理规则。这里，我们要使用三个 FBO 对象，因为第一个 FBO 对象在渲染原始图形之后，还要在混合效果时使用，后两个对象是用来交替使用完成高斯模糊的。最后，我们再将原始图形和高斯模糊的结果进行效果混合就可以了。修改后的代码如下：

// 创建三个FBO对象，fbo1和fbo2交替使用
const fbo0 = renderer.createFBO();
const fbo1 = renderer.createFBO();
const fbo2 = renderer.createFBO();

// 第一次，渲染原始图形
renderer.bindFBO(fbo0);
renderer.render();

// 第二次，对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo0.texture;
renderer.uniforms.axis = 0;
renderer.uniforms.filter = 0.7;
renderer.render();

// 第三次，对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();

// 第四次，对x轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = .texture;
renderer.uniforms.axis = 0;
renderer.uniforms.filter = 0;
renderer.render();

// 第五次，对y轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();

// 第六次，叠加辉光
renderer.useProgram(bloomProgram);
renderer.setMeshData(program.meshData);
renderer.bindFBO(null);
renderer.uniforms.tSource = fbo0.texture;
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 1;
renderer.uniforms.filter = 0;
renderer.render();

这样渲染之后，我就能让三角形图案中几个比较亮的三角形，产生一种微微发光的效果。渲染效果如下：

局部辉光效果示意图

这样，我们就实现了最终的局部辉光效果。实现它的关键，就是在高斯模糊原理的基础上，将局部高斯模糊的图像与原始图像叠加。

如何用后期处理通道实现烟雾效果？

除了模糊和辉光效果之外，后期处理通道还经常用来实现烟雾效果。接下来，我们就实现一个小圆的烟雾效果。具体的实现过程主要分为两步：第一步和前面两个例子一样，我们通过创建一个 shader，画出一个简单的圆。第二步，我们对这个圆进行后期处理，不过这次的处理方法就和实现辉光不同了。

下面，我们就一起来看。

首先，我们创建一个简单的 shader，也就是使用距离场在画布上画一个圆。这个 shader 我们非常熟悉，具体的代码如下：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec2 st = vUv - vec2(0.5);
  float d = length(st);
  gl_FragColor.rgb = vec3(1.0 - smoothstep(0.05, 0.055, d));
  gl_FragColor.a = 1.0;
}

接着，我们修改一下 shader 代码，增加 uTime、tMap 这两个变量，代码如下所示。其中，uTime 用来控制图像随时间变化，而 tMap 是我们用来做后期处理的变量。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;
uniform float uTime;

void main() {
  vec3 smoke = vec3(0);
  if(uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }
  vec3 diffuse = texture2D(tMap, vUv).rgb;
  gl_FragColor.rgb = diffuse + smoke;
  gl_FragColor.a = 1.0;
}

然后，我们依然创建两个 FBO，用它们交替进行绘制。最后，我们把绘制的内容输出到画布上。这里，我使用了一个 if 语句，根据绘制过程判断初始绘制还是后续的叠加过程，就能把着色器合并成一个。这样一来，不管是输出到画布还是 FBO，我们使用同一个 program 就可以了。

const fbo = {
  readFBO: renderer.createFBO(),
  writeFBO: renderer.createFBO(),
  get texture() {
    return this.readFBO.texture;
  },
  swap() {
    const tmp = this.writeFBO;
    this.writeFBO = this.readFBO;
    this.readFBO = tmp;
  },
};

function update(t) {
  // 输出到画布
  renderer.bindFBO(null);
  renderer.uniforms.uTime = t / 1000;
  renderer.uniforms.tMap = fbo.texture;
  renderer.render();
  // 同时输出到FBO
  renderer.bindFBO(fbo.writeFBO);
  renderer.uniforms.tMap = fbo.texture;
  // 交换读写缓冲以便下一次写入
  fbo.swap();
  renderer.render();
  requestAnimationFrame(update);
}
update(0);

你会发现，上面的代码执行以后输出的画面并没有什么变化。这是为什么呢？因为我们第一次渲染时，也就是当 uTime 为 0 的时候，我们直接画了一个圆。而当我们从上一次绘制的纹理中获取信息，重新渲染时，因为每次获取的纹理图案都是不变的，所以现在的画面依然是静止的圆。

如果我们想让这个图动起来，比如说让它向上升，那么我们只要在每次绘制的时候，改变一下采样的 y 坐标，就是每次从 tMap 取样时取当前纹理坐标稍微下方一点的像素点就可以了。具体的操作代码如下：

void main() {
  vec3 smoke = vec3(0);
  if(uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }
  vec2 st = vUv;
  st.y -= 0.001;
  vec3 diffuse = texture2D(tMap, st).rgb;
  gl_FragColor.rgb = diffuse + smoke;
  gl_FragColor.a = 1.0;
}

不过，由于纹理采样精度的问题，我们得到的上升圆还会有一个扩散的效果。不过这没有关系，它不影响我们接下来要实现的烟雾效果。

接下来，我们需要构建一个烟雾的扩散模型，也就是以某个像素位置以及周边像素的纹理颜色来计算新的颜色值。为了方便你理解，我就以一个 5*5 的画布为例来详细说说。假设，这个画布只有中心五个位置的颜色是纯白（1.0），周围都是黑色，如下图所示。

在这个扩散模型中，每个格子到下一时刻的颜色变化量，等于它周围四个格子的颜色值之和减去它自身颜色值的 4 倍，乘以扩散系数。

假设扩散系数是常量 0.1，那么第一轮每一格的颜色值我在表格上标出来了，如下图所示。

可以看到，上图中有三种颜色的格子，分别是红色、蓝色和绿色。下面，我们直接来看颜色值的计算过程。

首先是中间红色的那个格子。因为它四周的格子颜色都是 1.0，所以它的颜色变化量是：0.1 * ((1.0 + 1.0 + 1.0 + 1.0) - 4 * 1.0) = 0，那么下一帧的颜色值还是 1.0 不变。

其次，红格子周围的四个蓝色格子。它们下一帧的颜色变化量为：0.1 * ((1.0 + 0 + 0 + 0)- 4 * 1.0) = -0.3，那么它们下一帧的颜色值都要减去 0.3 就是 0.7。

最后，在计算绿色格子下一帧的颜色值时，要分为两种情况。

第一种，当要计算的绿色格子和两个蓝色格子相邻的时候，颜色变化量为：0.1 * ((1.0 + 1.0 + 0 + 0) - 4 * 0) = 0.2，所以绿格子下一帧的颜色值变为 0.2。

第二种，当这个绿色格子只和一个蓝色格子相邻的时候，颜色变化量为 0.1，那么绿格子下一帧的颜色值就变为 0.1。

就这样，我们把每一帧颜色按照这个规则不断迭代下去，就能得到一个烟雾扩散效果了。那我们下一步就是把它实现到 Shader 中，不过，在 Fragment Shader 中添加扩散模型的时候，为了让这个烟雾效果，能上升得更明显，我稍稍修改了一下扩散公式的权重，让它向上的幅度比较大。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;
uniform float uTime;

void main() {
  vec3 smoke = vec3(0);
  if(uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(step(d, 0.05));
    // smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }

  vec2 st = vUv;

  float offset = 1.0 / 256.0;
  vec3 diffuse = texture2D(tMap, st).rgb;

  vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
  vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
  vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
  vec4 down = texture2D(tMap, st + vec2(0.0, offset));

  float diff = 8.0 * 0.016 * (
    left.r +
    right.r +
    down.r +
    2.0 * up.r -
    5.0 * diffuse.r
  );

  gl_FragColor.rgb = (diffuse + diff) + smoke;
  gl_FragColor.a = 1.0;
}

你会发现，这个效果还不是特别真实。那为了达到更真实的烟雾效果，我们还可以在扩散函数上增加一些噪声，代码如下：

void main() {
  vec3 smoke = vec3(0);
  if(uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(step(d, 0.05));
    // smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }

  vec2 st = vUv;

  float offset = 1.0 / 256.0;
  vec3 diffuse = texture2D(tMap, st).rgb;

  vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
  vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
  vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
  vec4 down = texture2D(tMap, st + vec2(0.0, offset));

  float rand = noise(st + 5.0 * uTime);
  float diff = 8.0 * 0.016 * (
    (1.0 + rand) * left.r +
    (1.0 - rand) * right.r +
    down.r +
    2.0 * up.r -
    5.0 * diffuse.r
  );

  gl_FragColor.rgb = (diffuse + diff) + smoke;
  gl_FragColor.a = 1.0;
}

这样，我们最终实现的效果看起来就会更真实一些：

要点总结

今天，我们学习了怎么在 WebGL 中，使用后期处理通道来增强图像的视觉效果。我们讲了两个比较常用的效果，一个是 Blur 滤镜以及基于它实现辉光效果，另一个是烟雾效果。

那后期处理通道实现这些效果的核心原理其实都是一样的，都是把第一次渲染后的内容输出到帧缓冲对象 FBO 中，然后把这个对象的内容作为纹理图片，再进行下一次渲染，这个渲染的过程可以重复若干次。最后，我们再把结果输出到屏幕上，就可以了。

到这里，视觉基础篇的内容，我们就全部讲完了。在这个模块里，我们围绕处理图像的细节，系统地学习了怎么表示颜色，怎么生成重复图案，怎么构造和使用滤镜处理图像，怎么进行纹理造型，还有怎么使用不同的坐标系绘图，怎么使用噪声和网格噪声生成复杂纹理、以及今天学习的怎么使用后期处理通道增强图像。我把核心的内容总结成了一张脑图，你可以借助它，来复习巩固这一模块的内容，查缺补漏。

小试牛刀

第一题，今天，我们实现的烟雾扩散效果还不够完善，也不够有趣，你能试着改进它，让它变得更有趣吗？你可以参考我给出的两个建议，也可以试试其他的效果。

1. 给定一个向量，表示风向和风速，让烟雾随着这个风扩散，这个风可以随着时间慢慢变化（你可以使用前面学过的噪声来实现风的变化），看看能够做出什么效果。

2. 尝试让烟雾跟随着鼠标移动轨迹扩散，达到类似下面的效果。

如果你觉得难以实现，这里有一篇文章详细讲了烟雾生成的方法，你可以仔细研究一下。

第二题，实际上，后期处理通道可不止是实现 Blur 滤镜、辉光或者烟雾效果那么简单，它还可以实现很多不同的功能。比如，SpriteJS 官网上那种类似于探照灯的效果，就是用后期处理通道实现的。你可以用 gl-renderer 来实现这类效果吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课再见！

源码

完整的示例代码见GitHub 仓库

推荐阅读

[1] 原始 FBO 对象的创建和使用方法

[2] How to Write a Smoke Shader

18 | 如何生成简单动画让图形动起来？

你好，我是月影。

前面，我们用了 3 个模块的时间，学习了大量的图形学和数学知识，是不是让你的脑袋有一点昏沉？没关系，你只是需要一点时间来消化这些知识而已。我能给你的建议就是多思考、多练习，有了时间的积累，你一定可以掌握这些基础知识和思维方法。

从这一节课开始，我们要学习一个非常有意思的新模块，那就是动画和 3D 绘图。对于可视化展现来说，动画和 3D 都是用来强化数据表达，吸引用户的重要技术手段。它们往往比二维平面图形能够表达更复杂的数据，实现更吸引人的视觉效果。

那今天，我们先来聊聊动画的实现。实际上，我们之前也实现了不少动态效果，但你可能还是不知道怎么去实现动画。接下来，我们就来系统地梳理一下动画实现的标准方法。

动画的三种形式

什么是动画呢？简单来说，动画就是将许多帧静止的画面以固定的速率连续播放出来。一般来说，动画有三种形式，分别是固定帧动画、增量动画和时序动画。

第一种形式是我们预先准备好要播放的静态图像，然后将这些图依次播放，所以它叫做固定帧动画。增量动画是在动态绘制图像的过程中，我们修改每一帧中某个或某几个属性的值，给它们一定的增量。第三种形式是在动态绘制图像的过程中，我们根据时间和动画函数计算每一帧中的关键属性值，然后更新这些属性，所以它叫做时序动画。

这么说还是比较抽象，下面，我就以 HTML/CSS 为例，来带你熟悉这三种动画的基本形式。为什么选 HTML/CSS 呢？因为一般来说，HTML/CSS、SVG 和 Canvas2D 实现动画的方式大同小异，所以我就直接选择你最熟悉的 HTML/CSS 了。而 WebGL 实现动画的方式和其他三种图形系统都有差别，所以我会在下一节课单独来说。

1. 实现固定帧动画

首先，我们来说说如何实现固定帧动画。

结合固定帧动画的定义，我们实现它的第一步，就是为每一帧准备一张静态图像。比如说，我们要实现一个循环播放 3 帧的动画，就要准备 3 个如下的图像。

3个静态图像

第二步，我们要依次播放这些图像。在 CSS 里实现的时候，我们使用图片作为背景，就可以让它们逐帧切换了。代码如下所示：

.bird {
  position: absolute;
  left: 100px;
  top: 100px;
  width:86px;
  height:60px;
  zoom: 0.5;
  background-repeat: no-repeat;
  background-image: url(https://p.ssl.qhimg.com/t01f265b6b6479fffc4.png);
  background-position: -178px -2px;
  animation: flappy .5s step-end infinite;
}

@keyframes flappy {
  0% {background-position: -178px -2px;}
  33% {background-position: -90px -2px;}
  66% {background-position: -2px -2px;}
}

动态的小鸟

虽然固定帧动画实现起来非常简单，但它不适合生成需要动态绘制的图像，更适合在游戏等应用场景中，生成由美术提供现成图片的动画帧图像。而对于动态绘制的图像，也就是非固定帧动画，我们通常会使用另外两种方式。

2. 实现增量动画

我们先来说比较简单的增量动画，即每帧给属性一个增量。怎么理解呢？我举个简单的例子，我们可以创建一个蓝色的方块，然后给这个方块的每一帧增加一个 rotate 角度。这样就能实现蓝色方块旋转的动画。具体的代码和效果如下所示。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .block {
      width: 100px;
      height: 100px;
      top: 100px;
      left: 100px;
      transform-origin: 50% 50%;
      position: absolute;
      background: blue;
    }
  </style>
</head>
<body>
  <div class="block"></div>
  <script>
  const block = document.querySelector('.block');
  let rotation = 0;
  requestAnimationFrame(function update() {
    block.style.transform = `rotate(${rotation++}deg)`;
    requestAnimationFrame(update);
  });
  </script>
</body>
</html>

旋转的蓝色方块

在上面的例子中，我们重点关注第 22 到 26 这 5 行 JavaScript 代码就行了，关键逻辑在于我们修改 rotatation 值，每次绘制的时候将它加 1。这样我们就实现增量动画，是不是也很简单？

确实，增量动画的优点就是实现简单。但它也有 2 个缺点。首先，因为它使用增量来控制动画，从数学角度来说，也就是我们直接使用了一阶导数来定义的动画。这样的绘图方式不太好控制动画的细节，比如动画周期、变化率、轨迹等等，所以这种方法只能用来实现简单动画。

其次，增量动画定义的是状态变化。如果我们要在 shader 中使用动画，就只能采用后期处理通道来实现。但是后期处理通道要进行多次渲染，实现起来比较繁琐，而且性能开销也比较大。所以，更加复杂的轨迹动画，我们一般采用第三种方式，也就是通过定义时间和动画函数来实现。

3. 实现时序动画

还是以旋转的蓝色方块为例，我们改写一下它的 JavaScript 代码。

const block = document.querySelector('.block');
const startAngle = 0;
const T = 2000;
let startTime = null;
function update() {
  startTime = startTime == null ? Date.now() : startTime;
  const p = (Date.now() - startTime) / T;
  const angle = startAngle + p * 360;
  block.style.transform = `rotate(${angle}deg)`;
  requestAnimationFrame(update);
}
update();

首先，我们定义 2 个变量，startAnglehe 和 T。其中，startAnglehe 是起始旋转角度，T 是旋转周期。在第一次调用 update 的时候，我们设置初始旋转的时间为 startTime，那么在每次调用 update 的时候，当前经过的时间就是 Date.now() - startTime。

接着，我们将它除以周期 T，就能得到旋转进度 p，那么当前角度就等于 startAngle + p * 360。然后我们将当前角度设置为元素的 rotate 值，就实现了同样的旋转动画。

总的来说，时序动画的实现可以总结为三步：首先定义初始时间和周期，然后在 update 中计算当前经过时间和进度 p，最后通过 p 来更新动画元素的属性。虽然时序动画实现起来比增量动画写法更复杂，但我们可以更直观、精确地控制旋转动画的周期（速度）、起始角度等参数。

也正因为如此，这种方式在动画实现中最为常用。那为了更方便使用和拓展，我们可以把实现时序动画的三个步骤抽象成标准的动画模型。具体怎么做呢？我们接着往下看。

定义标准动画模型

首先，我们定义一个类 Timing 用来处理时间，具体代码如下：

export class Timing {
  constructor({duration, iterations = 1} = {}) {
    this.startTime = Date.now();
    this.duration = duration;
    this.iterations = iterations;
  }

  get time() {
    return Date.now() - this.startTime;
  }

  get p() {
    const progress = Math.min(this.time / this.duration, this.iterations);
    return this.isFinished ? 1 : progress % 1;
  }

  get isFinished() {
    return this.time / this.duration >= this.iterations;
  }
}

然后，我们实现一个 Animator 类，用来真正控制动画过程。

import {Timing} from './timing.js';

export class Animator {
  constructor({duration, iterations}) {
    this.timing = {duration, iterations};
  }

  animate(target, update) {
    let frameIndex = 0;
    const timing = new Timing(this.timing);

    return new Promise((resolve) => {
      function next() {
        if(update({target, frameIndex, timing}) !== false && !timing.isFinished) {
          requestAnimationFrame(next);
        } else {
          resolve(timing);
        }
        frameIndex++;
      }
      next();
    });
  }
}

Animator 构造器接受{duration, iterations}作为参数，它有一个 animate 方法，会在执行时创建一个 timing 对象，然后通过执行 update({target, frameIndex, timing}) 更新动画，并且会返回一个 promise 对象。这样，在动画结束时，resolve 这个 promise，我们就能够很方便地实现连续动画了。

接下来，你可以想一个动画效果，来试验一下这个模型的效果。比如说，我们可以用 Animator 实现四个方块的轮换转动，让每个方块转动的周期是 1 秒，一共旋转 1.5 个周期（即 540 度）。代码和效果如下所示。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .container {
      display: flex;
      flex-wrap: wrap;
      justify-content: space-between;
      width: 300px;
    }
    .block {
      width: 100px;
      height: 100px;
      margin: 20px;
      flex-shrink: 0;
      transform-origin: 50% 50%;
    }
    .block:nth-child(1) {background: red;}
    .block:nth-child(2) {background: blue;}
    .block:nth-child(3) {background: green;}
    .block:nth-child(4) {background: orange;}
  </style>
</head>
<body>
  <div class="container">
    <div class="block"></div>
    <div class="block"></div>
    <div class="block"></div>
    <div class="block"></div>
  </div>
  <script type="module">
    import {Animator} from '../common/lib/animator/index.js';
    const blocks = document.querySelectorAll('.block');
    const animator = new Animator({duration: 1000, iterations: 1.5});
    (async function () {
      let i = 0;
      while(true) { // eslint-disable-next-line no-await-in-loop
        await animator.animate(blocks[i++ % 4], ({target, timing}) => {
          target.style.transform = `rotate(${timing.p * 360}deg)`;
        });
      }
    }());
  </script>
</body>
</html>

顺序旋转的四个方块

插值与缓动函数

我们前面说过，时序动画的好处就在于，它能更容易地控制动画的细节。那针对我们总结出的这个标准的动画模型，它又如何控制动画细节呢？

假设，我们已知元素的起始状态、结束状态和运动周期。如果想要让它进行不规则运动，我们可以使用插值的方式来控制每一帧的展现。比如说，我们可以先实现一个匀速运动的方块，再通过插值与缓动函数来实现变速运动。

首先，我们用 Animator 实现一个方块，让它从 100px 处匀速运动到 400px 处。注意，在代码实现的时候，我们使用了一个线性插值方法：left = start * (1 - p) + end * p。线性插值可以很方便地实现属性的均匀变化，所以用它来让方块做匀速运动是非常简单的。但如果是让方块非匀速运动，比如匀加速运动，我们要怎么办呢？

import {Animator} from '../common/lib/animator/index.js';
const block = document.querySelector('.block');
const animator = new Animator({duration: 3000});
document.addEventListener('click', () => {
  animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
    const left = start * (1 - p) + end * p;
    el.style.left = `${left}px`;
  });
});

实现技巧也很简单，我们仍然可以使用线性插值，只不过要对插值参数 p 做一个函数映射。比如说，如果要让方块做初速度为 0 的匀加速运动，我们可以将 p 映射为 p^2。

p = p ** 2;
const left = start * (1 - p) + end * p;

再比如说，如果要让它做末速度为 0 的匀减速运动，我们可以将 p 映射为 p * (2 - p)。

p = p * (2 - p);
const left = start * (1 - p) + end * p;

那为什么匀加速、匀减速的时候，p 要这样映射呢？要理解这一点，我们就得先来回忆一下，匀加速和匀减速运动的物理计算公式。

假设，某个物体在做初速度为 0 的匀加速运动，运动的总时间为 T，总位移为 S。那么，它在 t 时刻的位移和加速度的计算公式如下：

匀加速运动的计算公式

所以我们把 p 映射为 p 的平方。

还是同样的情况下，如果物体在做匀减速运动，那么，它在 t 时刻的位移和加速度的计算公式如下：

匀变速运动的计算公式

所以我们把 p 映射为 p(2-p)。

除此以外，我们还可以将 p 映射为三次曲线 p * p * (3.0 - 2.0 * p) ，来实现 smoothstep 的插值效果等等。那为了方便使用以及实现更多的效果，我们可以抽象出一个映射函数专门处理 p 的映射，这个函数叫做缓动函数（Easing Function）。

我们可以在前面实现过的 Timing 类中，直接增加一个缓动函数 easing。这样在获取 p 值的时候，我们直接用 this.easing(progress) 取代之前的 progress，就可以让动画变速运动了。修改后的代码如下：

export class Timing {
  constructor({duration, iterations = 1, easing = p => p} = {}) {
    this.startTime = Date.now();
    this.duration = duration;
    this.iterations = iterations;
    this.easing = easing;
  }

  get time() {
    return Date.now() - this.startTime;
  }

  get p() {
    const progress = Math.min(this.time / this.duration, this.iterations);
    return this.isFinished ? 1 : this.easing(progress % 1);
  }

  get isFinished() {
    return this.time / this.duration >= this.iterations;
  }
}

那带入到具体的例子中，我们只要多给 animator 传一个 easing 参数，就可以让一开始匀速运动的小方块变成匀加速运动了。下面就是我们使用这个缓动函数的具体代码：

import {Animator} from '../common/lib/animator/index.js';
const block = document.querySelector('.block');
const animator = new Animator({duration: 3000, easing: p => p ** 2});
document.addEventListener('click', () => {
  animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
    const left = start * (1 - p) + end * p;
    el.style.left = `${left}px`;
  });
});

贝塞尔曲线缓动

现在，我们已经缓动函数的应用了。缓动函数有很多种，其中比较常用的是贝塞尔曲线缓动（Bezier-easing），准确地说，是三次贝塞尔曲线缓动函数。接下来，我们就来一起来实现一个简单的贝塞尔曲线缓动。

我们先来复习一下三次贝塞尔曲线的参数方程：

对于贝塞尔曲线图形来说，t 是参数，P 是坐标。而贝塞尔曲线缓动函数，则是把 Px 作为时间参数 p，把 Py 作为 p 的映射。这样，我们就知道了参数方程和缓动函数之间映射关系了。

贝塞尔缓动函数，图盘来源：React.js

那要想把三次贝塞尔曲线参数方程变换成贝塞尔曲线缓动函数，我们可以使用一种数学方法，叫做牛顿迭代法（Newton’s method）。因为这个方法比较复杂，所以我就不展开细说了。

我们可以使用现成的 JavaScript 库bezier-easing来生成贝塞尔缓动函数，例如：

import {Animator} from '../common/lib/animator/index.js';
const block = document.querySelector('.block');
const animator = new Animator({duration: 3000, easing: BezierEasing(0.5, -1.5, 0.5, 2.5)});
document.addEventListener('click', () => {
  animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
    const left = start * (1 - p) + end * p;
    el.style.left = `${left}px`;
  });
});

这样，我们能得到如下的效果：

实际上，CSS3 动画原生支持 bezier-easing。所以上面的效果，我们也可以使用 CSS3 动画来实现。

.container {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  width: 300px;
}
.block {
  width: 100px;
  height: 100px;
  position: absolute;
  top: 100px;
  left: 100px;
  background: blue;
  flex-shrink: 0;
  transform-origin: 50% 50%;
}
.animate {
  animation: mymove 3s cubic-bezier(0.5, -1.5, 0.5, 2.5) forwards;
}
@keyframes mymove {
  from {left: 100px}
  to {left: 400px}
}

其实贝塞尔缓动函数还有很多种，你可以去easing.net这个网站里看一看，然后尝试利用里面提供的缓动函数，来修改我们例子代码中的效果，看看动画过程有什么不同。

要点总结

这节课，我们讲了动画的三种形式和实现它们的基本方法，并且我们重点讨论了由时序动画衍生的标准动画模型，以及在此基础上，利用线性插值和缓动函数来控制更多动画细节。

首先，我们来回顾一下这三种形式的实现方法和各自的特点：

第一种，固定帧动画。它实现起来最简单，只需要我们为每一帧准备一张图片，然后循环播放就可以了。

第二种，增量动画。虽然在实现的时候，我们需要在每帧给元素的相关属性增加一定的量，但也很好操作，就是不好精确控制动画细节。

第三种是使用时间和动画函数来描述的动画，也叫做时序动画。这种方法能够非常精确地控制动画的细节，所以它能实现的动画效果更丰富，应用最广泛。

然后，为了更方便使用，我们根据时序动画定义了标准动画模型，实现了 Animator 类。基于此，我们就可以使用线性插值来实现动画的匀速运动，通过缓动函数来改变动画的运动速度。

在动画的实现中，比较常用贝塞尔曲线缓动函数。它是通过对贝塞尔曲线方程进行牛顿迭代求出，我们可以使用 bezier-easing 库来创建贝塞尔缓动函数。CSS3 动画原生支持 bezier-easing，所以如果使用 HTML/CSS 方式绘制元素，我们可以尽量使用 CSS3 动画。

小试牛刀

最后，我希望你能利用我们今天学到的时序动画，来实现一个简单的动画效果。就是我们假设，有一个半径为 10px 的弹性小球，我们让它以自由落体的方式下落 200px 高度。在这个过程中，小球每次落地后弹起的高度会是之前的一半，然后它会不断重复自由下落的过程，直到静止在地面上。

你能试着用标准动画模型封装好的 Animator 模块，来实现这个效果吗？Animator 模块的代码你可以在 Github 仓库中找到，也可以直接按照我们前面讲解内容自己实现一下。

源码

本节课的完整示例代码见GitHub 仓库

推荐阅读

[1] 牛顿迭代法

[2] Bezier-easing

[3] Easing.net

19 | 如何用着色器实现像素动画？

你好，我是月影。

上节课，我们以 HTML/CSS 为例，讲了三种动画的实现方法，以及标准的动画模型。我们先来回顾一下：

固定帧动画：为每一帧准备一张图片，然后把 CSS 关键帧动画的 easing-function 设为 step-end 进行循环播放。

增加增量动画：在每帧给元素的相关属性增加一定的量，比如增加一个 rotate 角度。

时序动画：通过控制时间和动画函数来描述动画，首先定义初始时间和周期，然后在 update 中计算当前经过时间和进度 p，最后通过 p 来更新动画元素的属性。

标准动画模型：先定义 Animator 类，然后使用线性插值实现匀速运动的动画，以及通过缓动函数实现变速运动的动画。

而 WebGL 实现动画的方式和以上这些方式都有差别。所以这节课，我们就接着来讲怎么用着色器来实现动画。

因为实现固定帧动画最简单，所以我们还是先来说它。

如何用着色器实现固定帧动画

我们完全可以使用在片元着色器中替换纹理坐标的方式，来非常简单地实现固定帧动画。为了方便对比，我还是用上一节课实现会飞的小鸟的例子来讲，那片元着色器中的代码和最终要实现的效果如下所示。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;
uniform float fWidth;
uniform vec2 vFrames[3];
uniform int frameIndex;

void main() {
  vec2 uv = vUv;
  for (int i = 0; i < 3; i++) {
    uv.x = mix(vFrames[i].x, vFrames[i].y, vUv.x) / fWidth;
    if(float(i) == mod(float(frameIndex), 3.0)) break;
  }
  vec4 color = texture2D(tMap, uv);
  gl_FragColor = color;
}

会飞的小鸟

利用片元着色器实现固定帧动画的关键部分，是 main 函数中的 for 循环。因为我们的动画只有 3 帧，所以最多只需要循环 3 次。

我们还需要一个重要的参数，vFrames。它是每一帧动画的图片起始 x 和结束 x 坐标，我们用这两个坐标和 vUv.x 计算插值，最后除以图片的总宽度 fWidth，就能得到对应的纹理 x 坐标。替换纹理坐标之后，我们就能实现一个会飞的小鸟了。

实现这个固定帧动画对应的 JavaScript 代码如下：

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const textureURL = 'https://p.ssl.qhimg.com/t01f265b6b6479fffc4.png';
(async function () {
  const texture = await renderer.loadTexture(textureURL);
  const program = renderer.compileSync(fragment, vertex);
  renderer.useProgram(program);
  renderer.uniforms.tMap = texture;
  renderer.uniforms.fWidth = 272;
  renderer.uniforms.vFrames = [2, 88, 90, 176, 178, 264];
  renderer.uniforms.frameIndex = 0;
  setInterval(() => {
    renderer.uniforms.frameIndex++;
  }, 200);
  const x = 43 / canvas.width;
  const y = 30 / canvas.height;
  renderer.setMeshData([{
    positions: [
      [-x, -y],
      [-x, y],
      [x, y],
      [x, -y],
    ],
    attributes: {
      uv: [
        [0, 0],
        [0, 1],
        [1, 1],
        [1, 0],
      ],
    },
    cells: [[0, 1, 2], [2, 0, 3]],
  }]);
  renderer.render();
}());

实际上 WebGL 实现固定帧动画的思路，和上一节课的思路是类似的。只不过，上一节课我们直接用 CSS 的 background-image，来切换 background-position 就可以实现动画。而在这里，我们需要将图片纹理 tMap 传进去，然后根据不同的 frameIndex 来计算出对应的纹理坐标，并且这个计算是在片元着色器中进行的。

如何用着色器实现非固定帧动画

好了，知道了怎么实现固定帧动画。接着，我们再来说增量动画和时序动画的实现。由于这两种动画都要将与时间有关的参数传给着色器，处理过程非常相似，所以我们可以将它们统称为非固定帧动画，放在一起来说。

由于这两种动画都要将与时间有关的参数传给着色器，因此它们的处理过程非常相似，我们可以将它们统称为非固定帧动画，放在一起来说。

用 Shader 实现非固定帧动画，本质上和上一节课的实现方法没有太大区别。所以，我们仍然可以使用同样的方法，以及标准动画模型来实现它。只不过，用 Shader 来实现非固定帧动画更加灵活，我们可以操作更多的属性，实现更丰富的效果。下面，我们详细来说说。

1. 用顶点着色器实现非固定帧动画

我们知道，WebGL 有两种 Shader，分别是顶点着色器和片元着色器，它们都可以用来实现动画。我们先来看顶点着色器是怎么实现动画的。

attribute vec2 a_vertexPosition;
attribute vec2 uv;

varying vec2 vUv;
uniform float rotation;

void main() {
  gl_PointSize = 1.0;
  vUv = uv;
  float c = cos(rotation);
  float s = sin(rotation);
  mat3 transformMatrix = mat3(
    c, s, 0,
    -s, c, 0,
    0, 0, 1
  );
  vec3 pos = transformMatrix * vec3(a_vertexPosition, 1);
  gl_Position = vec4(pos, 1);
}

在顶点着色器中，我们先绘制出一个红色的正方形，然后用三维齐次矩阵实现旋转。具体来说，就是把顶点坐标进行矩阵运算，再配合下面的 JavaScript 代码，就能让这个正方形旋转了。

renderer.uniforms.rotation = 0.0;

requestAnimationFrame(function update() {
  renderer.uniforms.rotation += 0.05;
  requestAnimationFrame(update);
});

逆时针旋转的红色正方形

当然，我们也可以使用上一节课得到的标准动画模型来实现。具体来说，就是定义一个新的 Animator 对象，然后在 Animator 对象的方法中更新 rotation 属性。使用标准模型能更加精确地控制图形的旋转效果，代码如下：

const animator = new Animator({duration: 2000, iterations: Infinity});
animator.animate(renderer, ({target, timing}) => {
  target.uniforms.rotation = timing.p * 2 * Math.PI;
});

总之，WebGL 实现非固定帧动画的方法与上节课的方式基本上一样。只不过，前一节课我们直接修改 HTML 元素的属性，而这一节课我们将属性通过 uniform 变量传给着色器执行渲染。

2. 用片元着色器实现非固定帧动画

除了用顶点着色器，我们也能用片元着色器实现动画。实际上，我们已经用片元着色器实现了不少动画。比如说，当我们将时间参数 uTime 通过 uniform 传给着色器的时候，就是在实现动画。

还是用上面的例子。这次，我们将旋转放到片元着色器中处理，其实也能实现类似的旋转效果。代码如下所示：

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec4 color;
uniform float rotation;

void main() {
  vec2 st = 2.0 * (vUv - vec2(0.5));
  float c = cos(rotation);
  float s = sin(rotation);
  mat3 transformMatrix = mat3(
    c, s, 0,
    -s, c, 0,
    0, 0, 1
  );
  vec3 pos = transformMatrix * vec3(st, 1.0);
  float d1 = 1.0 - smoothstep(0.5, 0.505, abs(pos.x));
  float d2 = 1.0 - smoothstep(0.5, 0.505, abs(pos.y));
  gl_FragColor = d1 * d2 * color;
}

顺时针旋转的红色正方形

你发现了吗，顶点着色器和片元着色器实现的旋转动画方向正好相反。为什么会出现这样的情况呢？因为在顶点着色器中，我们直接改变了顶点坐标，所以这样实现的旋转动画和 WebGL 坐标系（右手系）的方向一致，角度增大呈逆时针方向旋转。而在片元着色器中，我们的绘制原理是通过距离场着色来实现的，所以这里的旋转实际上改变的是距离场的角度而不是图形角度，最终绘制的图形也是相对于距离场的。又因为距离场逆时针旋转，所以图形就顺时针旋转了。

最后我再补充一点，一般来说，动画如果能使用顶点着色器实现，我们会尽量在顶点着色器中实现。因为在绘制一帧画面的时候，顶点着色器的运算量会大大少于片元着色器，所以使用顶点着色器消耗的性能更少。

但是，在片元着色器中实现非固定帧动画也有优势。我们可以使用片元着色器的技巧，如重复、随机、噪声等等来绘制更加复杂的效果。

比如说，我们把上面的代码稍微修改一下，使用取小数和取整数的函数，再用之前网格化的思路，来利用网格实现了大量的重复动画。这个做法充分利用了 GPU 的并行效率，比用其他方式把图形一个一个地绘制出来性能要高得多。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform float rotation;

float random (vec2 st) {
    return fract(sin(dot(st.xy,
                        vec2(12.9898,78.233)))*
        43758.5453123);
}

vec3 hsb2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

void main() {
  vec2 f_uv = fract(vUv * 10.0);
  vec2 i_uv = floor(vUv * 10.0);
  vec2 st = 2.0 * (f_uv - vec2(0.5));
  float c = 0.7 * cos(rotation);
  float s = 0.7 * sin(rotation);
  mat3 transformMatrix = mat3(
    c, s, 0,
    -s, c, 0,
    0, 0, 1
  );
  vec3 pos = transformMatrix * vec3(st, 1.0);
  float d1 = 1.0 - smoothstep(0.5, 0.505, abs(pos.x));
  float d2 = 1.0 - smoothstep(0.5, 0.505, abs(pos.y));
  gl_FragColor = d1 * d2 * vec4(hsb2rgb(vec3(random(i_uv), 1.0, 1.0)), 1.0);
}

大量重复的旋转正方形

如何在着色器中实现缓动函数与非线性插值

在前面的例子中，我们使用 Shader 的矩阵运算实现了旋转动画。同样，轨迹动画也可以用 Shader 矩阵运算实现。

比如说，我们要在画布上绘制一个红色的方块，利用它实现轨迹动画。首先，我们要实现一个着色器，它通过设置 translation 来改变图形位置，代码如下：

attribute vec2 a_vertexPosition;
attribute vec2 uv;

varying vec2 vUv;
uniform vec2 translation;

void main() {
  gl_PointSize = 1.0;
  vUv = uv;
  mat3 transformMatrix = mat3(
    1, 0, 0,
    0, 1, 0,
    translation, 1
  );
  vec3 pos = transformMatrix * vec3(a_vertexPosition, 1);
  gl_Position = vec4(pos, 1);
}

然后，在 JavaScript 中，我们将 translation 依照时间变化传给上面的着色器，就可以让方块移动。那利用下面的代码，我们就让方块沿水平方向向右匀速运动一段距离。

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.uniforms.color = [1, 0, 0, 1];
renderer.uniforms.translation = [-0.5, 0];

const animator = new Animator({duration: 2000});
animator.animate(renderer, ({target, timing}) => {
  target.uniforms.translation = [-0.5 * (1 - timing.p) + 0.5 * timing.p, 0];
});

renderer.setMeshData([{
  positions: [
    [-0.25, -0.25],
    [-0.25, 0.25],
    [0.25, 0.25],
    [0.25, -0.25],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);
renderer.render();

水平向右匀速运动的红色正方形

此外，我们还可以通过缓动函数来实现非匀速运动。而且我们既可以将缓动函数用 JavaScript 计算，也可以直接将缓动函数放在 Shader 中。如果将缓动函数用 JavaScript 计算，那么方法和上一节课完全一样，也就是给 Animator 传一个 easing 函数进去就可以了，这里我就不再重复了。但如果要将缓动函数写在 Shader 中，其实方法也非常简单。

我们以前面顶点着色器实现非固定帧动画的代码为例，这次，我们不使用 Animator，而是直接将时间 uTime 参数传入 Shader，然后在 Shader 中加入缓动函数。在这里，我们用 smooth(0.0, 1.0, p) 来让方块做平滑加速、减速运动。除此之外，你也可以替换缓动函数，比如 clamp(p * p, 0.0, 1.0) 或者 clamp(p * (2 - p) * 0.0, 1.0) 来实现匀加速、匀减速的运动效果。修改后的代码如下：

attribute vec2 a_vertexPosition;
attribute vec2 uv;

varying vec2 vUv;
uniform vec4 uFromTo;
uniform float uTime;

float easing(in float p) {
  return smoothstep(0.0, 1.0, p);
  // return clamp(p * p, 0.0, 1.0);
  // return clamp(p * (2 - p) * 0.0, 1.0);
}

void main() {
  gl_PointSize = 1.0;
  vUv = uv;
  vec2 from = uFromTo.xy;
  vec2 to = uFromTo.zw;
  float p = easing(uTime / 2.0);
  vec2 translation = mix(from, to, p);
  mat3 transformMatrix = mat3(
    1, 0, 0,
    0, 1, 0,
    translation, 1
  );
  vec3 pos = transformMatrix * vec3(a_vertexPosition, 1);
  gl_Position = vec4(pos, 1);
}

总之，因为 Shader 是在 GPU 中运算的，所以所有顶点都是被并行处理的。因此，通常情况下，我们在顶点着色器中执行缓动函数会更快。

不过，直接用 JavaScript 计算和放在顶点着色器里计算，差别也不是很大，但如果把它放在片元着色器里计算，因为要把每个像素点都计算一遍，所以性能消耗反而更大一些。那我们为什么还要在着色器中计算 easing 呢？这是因为，我们不仅可以利用 easing 控制动画过程，还可以在片元着色器中用 easing 来实现非线性的插值。

那什么是非线性插值呢？我们依然通过例子来进一步理解。

我们知道，在正常情况下，顶点着色器定义的变量在片元着色器中，都会被线性插值。比如，你可以看我下面给出的顶点着色器、片元着色器，以及 JavaScript 中的代码。

//顶点着色器
attribute vec2 a_vertexPosition;
attribute vec2 uv;
attribute vec4 color;

varying vec2 vUv;
varying vec4 vColor;
uniform vec4 uFromTo;
uniform float uTime;

void main() {
  gl_PointSize = 1.0;
  vUv = uv;
  vColor = color;
  gl_Position = vec4(a_vertexPosition, 1, 1);
}

//片元着色器

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
varying vec4 vColor;

void main() {
  gl_FragColor = vColor;
}

//JavaScript中的代码
renderer.setMeshData([{
  positions: [
    [-0.5, -0.25],
    [-0.5, 0.25],
    [0.5, 0.25],
    [0.5, -0.25],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
    color: [
      [1, 0, 0, 1],
      [1, 0, 0, 1],
      [0, 0.5, 0, 1],
      [0, 0.5, 0, 1],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);
renderer.render();

从左往右，由红色线性过渡到绿色

通过执行上面的代码，我们可以得到一个长方形，它的颜色会从左到右，由红色线性地过渡到绿色。如果想要实现非线性的颜色过渡，我们就不能采用这种方式了，我们可以采用 uniform 的方式，通过 easing 函数来实现。

#ifdef GL_ES
precision highp float;
#endif

float easing(in float p) {
  return smoothstep(0.0, 1.0, p);
  // return clamp(p * p, 0.0, 1.0);
  // return clamp(p * (2 - p) * 0.0, 1.0);
}

varying vec2 vUv;
uniform vec4 fromColor;
uniform vec4 toColor;

void main() {
  float d = easing(vUv.x);
  gl_FragColor = mix(fromColor, toColor, d);
}

比如，我们可以使用 smoothstep 这种 easing 函数，来实现如下的插值效果：

另外，我们还可以像利用 JavaScript 那样，在 Shader 里实现贝塞尔曲线缓动。

// http://www.flong.com/texts/code/shapers_bez/
// Helper functions:
float slope_from_t (float t, float A, float B, float C){
  float dtdx = 1.0/(3.0*A*t*t + 2.0*B*t + C);
  return dtdx;
}

float x_from_t (float t, float A, float B, float C, float D){
  float x = A*(t*t*t) + B*(t*t) + C*t + D;
  return x;
}

float y_from_t (float t, float E, float F, float G, float H){
  float y = E*(t*t*t) + F*(t*t) + G*t + H;
  return y;
}

float cubic_bezier (float x, float a, float b, float c, float d){
  float y0a = 0.00; // initial y
  float x0a = 0.00; // initial x
  float y1a = b;    // 1st influence y
  float x1a = a;    // 1st influence x
  float y2a = d;    // 2nd influence y
  float x2a = c;    // 2nd influence x
  float y3a = 1.00; // final y
  float x3a = 1.00; // final x

  float A = x3a - 3.0 *x2a + 3.0 * x1a - x0a;
  float B = 3.0 * x2a - 6.0 * x1a + 3.0 * x0a;
  float C = 3.0 * x1a - 3.0 * x0a;
  float D = x0a;

  float E = y3a - 3.0 * y2a + 3.0 * y1a - y0a;
  float F = 3.0 * y2a - 6.0 * y1a + 3.0 * y0a;
  float G = 3.0 * y1a - 3.0 * y0a;
  float H = y0a;

  // Solve for t given x (using Newton-Raphelson), then solve for y given t.
  // Assume for the first guess that t = x.
  float currentt = x;
  const int nRefinementIterations = 5;
  for (int i=0; i < nRefinementIterations; i++){
    float currentx = x_from_t(currentt, A,B,C,D);
    float currentslope = slope_from_t(currentt, A,B,C);
    currentt -= (currentx - x)*(currentslope);
    currentt = clamp(currentt, 0.0, 1.0);
  }

  float y = y_from_t(currentt, E,F,G,H);
  return y;
}

使用贝塞尔曲线缓动函数，我们能够实现更加丰富多彩的插值效果。

贝塞尔曲线插值色带

如何在片元着色器中实现随机粒子动画

我们知道，使用片元着色器还可以实现非常复杂的图形动画，包括粒子动画、网格动画以及网格噪声动画等等。网格动画和网格噪声我们前面都详细讲过，这里我们就重点来说说怎么实现粒子动画效果。

#ifdef GL_ES
precision highp float;
#endif

...

float sdf_circle(vec2 st, vec2 c, float r) {
  return 1.0 - length(st - c) / r;
}

varying vec2 vUv;
uniform float uTime;

void main() {
  vec2 st = vUv;
  float rx = mix(-0.2, 0.2, noise(vec2(7881.32, 0) + random(st) + uTime));
  float ry = mix(-0.2, 0.2, noise(vec2(0, 1433.59) + random(st) + uTime));
  float dis = distance(st, vec2(0.5));
  dis = pow((1.0 - dis), 2.0);
  float d = sdf_circle(st + vec2(rx, ry), vec2(0.5), 0.2);
  d = smoothstep(0.0, 0.1, d);
  gl_FragColor = vec4(dis * d * vec3(1.0), 1.0);
}

如上面代码所示，我们可以使用随机 + 噪声来实现一个粒子效果。首先，我们设置随机数用来生成距离场的初始值，然后设置噪声用来形成位移，最后传入 uTime 变量来实现动画。

这样一来，我们就能绘制出数量非常多的点，并且让它们沿着随机轨迹运动。最终的视觉效果如下：

粒子动画效果

像这样流畅的动画效果，因为实现的过程中会涉及非常多点的运算，如果不用 shader，我们几乎是无法完成的。

要点总结

这节课我们学习了用 WebGL 实现动画的方法。

如果是实现固定帧动画，在 WebGL 中，我们可以把准备好的图片作为纹理，然后动态修改纹理坐标。

如果是实现非固定帧动画，我们可以通过 uniform，将变化的属性作为参数传给着色器处理。上节课的标准动画模型在 WebGL 中依然可以使用，我们可以利用它计算出属性，再传入着色器执行渲染。

实际上，今天讲的方法，与用 HTML/CSS、SVG、Canvas2D 实现动画的基本原理是一样的。只不过，WebGL 中的很多计算，是需要用 JavaScript 和 GLSL，也就是 Shader 来配合进行的。

这节课的实战例子比较多，我建议你好好研究一下。毕竟，使用片元着色器实现动画效果的思路，我们还会在后续课程中经常用到。

小试牛刀

今天，我们在 Shader 中通过矩阵运算实现了图形的旋转和平移，你能用学到的知识完善矩阵运算，来实现缩放、旋转、平移和扭曲变换，以及它们的组合效果吗？

结合今天的内容，你可以试着实现一个粒子效果：让一张图片从中心爆炸开来，炸成碎片并最终消失。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

本节课完整示例代码见GitHub 仓库

20 | 如何用WebGL绘制3D物体？

你好，我是月影。这一节课开始，我们学习 3D 图形的绘制。

之前我们主要讨论的都是 2D 图形的绘制，实际上 WebGL 真正强大之处在于，它可以绘制各种 3D 图形，而 3D 图形能够极大地增强可视化的表现能力。

用 WebGL 绘制 3D 图形，其实在基本原理上和绘制 2D 图形并没有什么区别，只不过是我们把绘图空间从二维扩展到三维，所以计算起来会更加复杂一些。

今天，我们就从绘制最简单的三维立方体，讲到矩阵、法向量在三维空间中的使用，这样由浅入深地带你去了解，如何用 WebGL 绘制出各种 3D 图形。

如何用 WebGL 绘制三维立方体

首先，我们来绘制熟悉的 2D 图形，比如矩形，再把它拓展到三维空间变成立方体。代码如下：

// vertex shader  顶点着色器
attribute vec2 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = vec4(a_vertexPosition, 1, 1);
}

// fragment shader   片元着色器
#ifdef GL_ES
precision highp float;
#endif

varying vec4 vColor;

void main() {
  gl_FragColor = vColor;
}

...
// 顶点信息
renderer.setMeshData([{
  positions: [
    [-0.5, -0.5],
    [-0.5, 0.5],
    [0.5, 0.5],
    [0.5, -0.5],
  ],
  attributes: {
    color: [
      [1, 0, 0, 1],
      [1, 0, 0, 1],
      [1, 0, 0, 1],
      [1, 0, 0, 1],
    ],
  },
  cells: [[0, 1, 2], [0, 2, 3]],
}]);
renderer.render();

上面的 3 段代码，分别对应顶点着色器、片元着色器和基本的顶点信息。通过它们，我们就在画布上绘制出了一个红色的矩形。接下来，要想把 2 维矩形拓展到 3 维，我们的第一步就是要把顶点扩展到 3 维。这一步的操作比较简单，我们只需要把顶点从 vec2 扩展到 vec3 就可以了。

// vertex shader
attribute vec3 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = vec4(a_vertexPosition, 1);
}

然后，我们需要计算立方体的顶点数据。我们知道一个立方体有 8 个顶点，这 8 个顶点能组成 6 个面。在 WebGL 中，我们就需要用 12 个三角形来绘制它。如果每个面的属性相同，我们就可以复用 8 个顶点来绘制。而如果属性不同，比如每个面要绘制成不同的颜色，或者添加不同的纹理图片，我们还得把每个面的顶点分开。这样的话，我们一共需要 24 个顶点。

立方体8个顶点，6个面

为了方便使用，我们可以写一个 JavaScript 函数，用来生成立方体 6 个面的 24 个顶点，以及 12 个三角形的索引，而且我直接在这个函数里定义了每个面的颜色。具体的函数代码如下：

function cube(size = 1.0, colors = [[1, 0, 0, 1]]) {
  const h = 0.5 * size;
  const vertices = [
    [-h, -h, -h],
    [-h, h, -h],
    [h, h, -h],
    [h, -h, -h],
    [-h, -h, h],
    [-h, h, h],
    [h, h, h],
    [h, -h, h],
  ];

  const positions = [];
  const color = [];
  const cells = [];

  let colorIdx = 0;
  let cellsIdx = 0;
  const colorLen = colors.length;

  function quad(a, b, c, d) {
    [a, b, c, d].forEach((i) => {
      positions.push(vertices[i]);
      color.push(colors[colorIdx % colorLen]);
    });
    cells.push(
      [0, 1, 2].map(i => i + cellsIdx),
      [0, 2, 3].map(i => i + cellsIdx),
    );
    colorIdx++;
    cellsIdx += 4;
  }

  quad(1, 0, 3, 2);
  quad(4, 5, 6, 7);
  quad(2, 3, 7, 6);
  quad(5, 4, 0, 1);
  quad(3, 0, 4, 7);
  quad(6, 5, 1, 2);

  return {positions, color, cells};
}

这样，我们就可以构建出立方体的顶点信息，我在下面给出了 12 个立方体的顶点。

const geometry = cube(1.0, [
  [1, 0, 0, 1],
  [0, 0.5, 0, 1],
  [1, 0, 1, 1],
]);

通过上面的代码，我们就能创建出一个棱长为 1 的立方体，并且六个面的颜色分别是“红、绿、蓝、红、绿、蓝”。

这里我还想补充一点内容，绘制 3D 图形与绘制 2D 图形有一点不一样，那就是我们必须要开启深度检测和启用深度缓冲区。在 WebGL 中，我们可以通过gl.enable(gl.DEPTH_TEST)，来开启深度检测。

而且，我们在清空画布的时候，也要用gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);，来同时清空颜色缓冲区和深度缓冲区。启动和清空深度检测和深度缓冲区这两个步骤，是这个过程中非常重要的一环，但是我们几乎不会用原生的方式来写代码，所以我们了解到这个程度就可以了。

事实上，对于上面这些步骤，为了方便使用，我们还是可以直接使用 gl-renderer 库。它封装了深度检测，在使用它的时候，我们只要在创建 renderer 的时候设置一个参数 depth: true 即可。

现在，我们把这个三维立方体用 gl-renderer 渲染出来，渲染代码如下：

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas, {
  depth: true,
});

const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

renderer.setMeshData([{
  positions: geometry.positions,
  attributes: {
    color: geometry.color,
  },
  cells: geometry.cells,
}]);
renderer.render();

立方体的正视图，在画布上只呈现了一个红色正方形，因为其他面被遮挡住了

投影矩阵：变换 WebGL 坐标系

结合渲染出来的这个图形，我想让你再仔细观看一下我们刚才调用的代码。

当时立方体的顶点我们是这么定义的：

 const vertices = [
    [-h, -h, -h],
    [-h, h, -h],
    [h, h, -h],
    [h, -h, -h],
    [-h, -h, h],
    [-h, h, h],
    [h, h, h],
    [h, -h, h],

而立方体的六个面的颜色，我们是这么定义的：

//立方体的六个面
  quad(1, 0, 3, 2); // 红 -- 这一面应该朝内
  quad(4, 5, 6, 7);  // 绿 -- 这一面应该朝外
  quad(2, 3, 7, 6);  // 蓝
  quad(5, 4, 0, 1);  // 红
  quad(3, 0, 4, 7);  // 绿
  quad(6, 5, 1, 2);  // 蓝

有没有发现问题？我们之前说过，WebGL 的坐标系是 z 轴向外为正，z 轴向内为负，所以根据我们调用的代码，赋给靠外那一面的颜色应该是绿色，而不是红色。但是这个立方体朝向我们的一面却是红色，这是为什么呢？

实际上，WebGL 默认的剪裁坐标的 z 轴方向，的确是朝内的。也就是说，WebGL 坐标系就是一个左手系而不是右手系。但是，基本上所有的 WebGL 教程，也包括我们前面的课程，一直都在说 WebGL 坐标系是右手系，这又是为什么呢？

这是因为，规范的直角坐标系是右手坐标系，符合我们的使用习惯。因此，一般来说，不管什么图形库或图形框架，在绘图的时候，都会默认将坐标系从左手系转换为右手系。

那我们下一步，就是要将 WebGL 的坐标系从左手系转换为右手系。关于坐标转换，我们可以通过齐次矩阵来完成。将左手系坐标转换为右手系，实际上就是将 z 轴坐标方向反转，对应的齐次矩阵如下：

[
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, -1, 0,
  0, 0, 0, 1
]

这种转换坐标的齐次矩阵，又被称为投影矩阵（ProjectionMatrix）。接着，我们就修改一下顶点着色器，将投影矩阵加入进去。这样，画布上显示的就是绿色的正方形了。代码和效果图如下：

attribute vec3 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;
uniform mat4 projectionMatrix;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = projectionMatrix * vec4(a_vertexPosition, 1.0);
}

投影矩阵不仅可以用来改变 z 轴坐标，还可以用来实现正交投影、透视投影以及其他的投影变换，在下一节课我们会深入去讲。

模型矩阵：让立方体旋转起来

通过前面的操作，我们还是只能看到立方体的一个面，因为我们的视线正好是垂直于 z 轴的，所以其他的面被完全挡住了。不过，我们可以通过旋转立方体，将其他的面露出来。旋转立方体，同样可以通过矩阵运算来实现。这次我们要用到另一个齐次矩阵，它定义了被绘制的物体变换，这个矩阵叫做模型矩阵（ModelMatrix）。接下来，我们就把模型矩阵加入到顶点着色器中，然后将它与投影矩阵相乘，最后再乘上齐次坐标，就得到最终的顶点坐标了。

attribute vec3 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;
uniform mat4 projectionMatrix;
uniform mat4 modelMatrix;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = projectionMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);
}

接着，我们定义一个 JavaScript 函数，用立方体沿 x、y、z 轴的旋转来生成模型矩阵。我们以 x、y、z 三个方向的旋转得到三个齐次矩阵，然后将它们相乘，就能得到最终的模型矩阵。

import {multiply} from '../common/lib/math/functions/Mat4Func.js';

function fromRotation(rotationX, rotationY, rotationZ) {
  let c = Math.cos(rotationX);
  let s = Math.sin(rotationX);
  const rx = [
    1, 0, 0, 0,
    0, c, s, 0,
    0, -s, c, 0,
    0, 0, 0, 1,
  ];

  c = Math.cos(rotationY);
  s = Math.sin(rotationY);
  const ry = [
    c, 0, s, 0,
    0, 1, 0, 0,
    -s, 0, c, 0,
    0, 0, 0, 1,
  ];

  c = Math.cos(rotationZ);
  s = Math.sin(rotationZ);
  const rz = [
    c, s, 0, 0,
    -s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
  ];

  const ret = [];
  multiply(ret, rx, ry);
  multiply(ret, ret, rz);
  return ret;
}

最后，我们把这个模型矩阵传给顶点着色器，不断更新三个旋转角度，就能实现立方体旋转的效果，也就可以看到立方体其他各个面了。效果和代码如下所示：

let rotationX = 0;
let rotationY = 0;
let rotationZ = 0;

function update() {
  rotationX += 0.003;
  rotationY += 0.005;
  rotationZ += 0.007;
  renderer.uniforms.modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
  requestAnimationFrame(update);
}
update();

到这里，我们就完成了一个旋转的立方体。

如何用 WebGL 绘制圆柱体

立方体还是比较简单的几何体，那类似的，我们还可以构建顶点和三角形，来绘制更加复杂的图形，比如圆柱体、球体等等。这里，我再用绘制圆柱体来举个例子。

我们知道圆柱体的两个底面都是圆，我们可以用割圆的方式对圆进行简单的三角剖分，然后把圆柱的侧面用上下两个圆上的顶点进行三角剖分。

具体的算法如下：

function cylinder(radius = 1.0, height = 1.0, segments = 30, colorCap = [0, 0, 1, 1], colorSide = [1, 0, 0, 1]) {
  const positions = [];
  const cells = [];
  const color = [];
  const cap = [[0, 0]];
  const h = 0.5 * height;

  // 顶和底的圆
  for(let i = 0; i <= segments; i++) {
    const theta = Math.PI * 2 * i / segments;
    const p = [radius * Math.cos(theta), radius * Math.sin(theta)];
    cap.push(p);
  }

  positions.push(...cap.map(([x, y]) => [x, y, -h]));
  for(let i = 1; i < cap.length - 1; i++) {
    cells.push([0, i, i + 1]);
  }
  cells.push([0, cap.length - 1, 1]);

  let offset = positions.length;
  positions.push(...cap.map(([x, y]) => [x, y, h]));
  for(let i = 1; i < cap.length - 1; i++) {
    cells.push([offset, offset + i, offset + i + 1]);
  }
  cells.push([offset, offset + cap.length - 1, offset + 1]);

  color.push(...positions.map(() => colorCap));

  // 侧面
  offset = positions.length;
  for(let i = 1; i < cap.length; i++) {
    const a = [...cap[i], h];
    const b = [...cap[i], -h];
    const nextIdx = i < cap.length - 1 ? i + 1 : 1;
    const c = [...cap[nextIdx], -h];
    const d = [...cap[nextIdx], h];

    positions.push(a, b, c, d);
    color.push(colorSide, colorSide, colorSide, colorSide);
    cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
    offset += 4;
  }

  return {positions, cells, color};
}

这样呢，我们就可以绘制出圆柱体了，把前面例子代码里的 cube 改为 cylinder，效果如下图所示：

所以我们看到，用 WebGL 绘制三维物体，实际上和绘制二维物体没有什么本质不同，都是将图形（对于三维来说，也就是几何体）的顶点数据构造出来，然后将它们送到缓冲区中，再执行绘制。只不过三维图形的绘制需要构造三维的顶点和网格，在绘制前还需要启用深度缓冲区。

构造和使用法向量

在前面两个例子中，我们构造出了几何体的顶点信息，包括顶点的位置和颜色信息，除此之外，我们还可以构造几何体的其他信息，其中一种比较有用的信息是顶点的法向量信息。

法向量那什么是法向量呢？法向量表示每个顶点所在的面的法线方向，在 3D 渲染中，我们可以通过法向量来计算光照、阴影、进行边缘检测等等。法向量非常有用，所以我们也要掌握它的构造方法。

1. 构造法向量

对于立方体来说，得到法向量非常简单，我们只要找到垂直于立方体 6 个面上的线段，再得到这些线段所在向量上的单位向量就行了。显然，标准立方体中 6 个面的法向量如下：

[0, 0, -1]
[0, 0, 1]
[0, -1, 0]
[0, 1, 0]
[-1, 0, 0]
[1, 0, 0]

对于圆柱体来说，底面和顶面法线分别是 (0, 0, -1) 和 (0, 0, 1)。侧面的计算稍微复杂一些，需要通过三角网格来计算。具体怎么做呢？

因为几何体是由三角网格构成的，而法线是垂直于三角网格的线，如果要计算法线，我们可以借助三角形的顶点，使用向量的叉积定理来求。我们假设在一个平面内，有向量 a 和 b，n 是它们的法向量，那我们可以得到公式：n = a X b。

根据这个公式，我们可以通过以下方法求出侧面的法向量：

  const tmp1 = [];
  const tmp2 = [];
  // 侧面
  offset = positions.length;
  for(let i = 1; i < cap.length; i++) {
    const a = [...cap[i], h];
    const b = [...cap[i], -h];
    const nextIdx = i < cap.length - 1 ? i + 1 : 1;
    const c = [...cap[nextIdx], -h];
    const d = [...cap[nextIdx], h];

    positions.push(a, b, c, d);

    const norm = [];
    cross(norm, subtract(tmp1, b, a), subtract(tmp2, c, a));
    normalize(norm, norm);
    normal.push(norm, norm, norm, norm); // abcd四个点共面，它们的法向量相同
    color.push(colorSide, colorSide, colorSide, colorSide);
    cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
    offset += 4;
  }

求出法向量，我们可以使用法向量来实现丰富的效果，比如点光源。下面，我们就在 shader 中实现点光源效果。

2. 法向量矩阵

因为我们在 shader 中，会使用模型矩阵对顶点进行变换，所以在片元着色器中，我们拿到的是变换后的顶点坐标，这时候，如果我们要应用法向量，需要对法向量也进行变换，我们可以通过一个矩阵来实现，这个矩阵叫做法向量矩阵（NormalMatrix）。它是模型矩阵的逆转置矩阵，不过它非常特殊，是一个 3X3 的矩阵（mat3），而像模型矩阵、投影矩阵等等矩阵都是 4X4 的。

得到了法向量和法向量矩阵，我们可以使用法向量和法向量矩阵来实现点光源光照效果。首先，我们要实现如下顶点着色器：

attribute vec3 a_vertexPosition;
attribute vec4 color;
attribute vec3 normal;

varying vec4 vColor;
varying float vCos;
uniform mat4 projectionMatrix;
uniform mat4 modelMatrix;
uniform mat3 normalMatrix;

const vec3 lightPosition = vec3(1, 0, 0);

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  vec4 pos =  modelMatrix * vec4(a_vertexPosition, 1.0);
  vec3 invLight = lightPosition - pos.xyz;
  vec3 norm = normalize(normalMatrix * normal);
  vCos = max(dot(normalize(invLight), norm), 0.0);
  gl_Position = projectionMatrix * pos;
}

在上面顶点着色器的代码中，我们计算的是位于 (1,0,0) 坐标处的点光源与几何体法线的夹角余弦。那根据物体漫反射模型，光照强度等于光线与法向量夹角的余弦。

因此，我们求出这个余弦值，就能在片元着色器叠加光照了。操作代码和实现效果如下：

#ifdef GL_ES
precision highp float;
#endif

uniform vec4 lightColor;
varying vec4 vColor;
varying float vCos;

void main() {
  gl_FragColor.rgb = vColor.rgb + vCos * lightColor.a * lightColor.rgb;
  gl_FragColor.a = vColor.a;
}

用法向量计算出的光照效果

要点总结

今天，我们以绘制立方体和圆柱体为例，讲了用 WebGL 绘制三维几何体的基本原理。3D 绘图在原理上和 2D 绘图几乎是完全一样的，就是构建顶点数据，然后将数据送入缓冲区执行绘制。只是，2D 绘图用二维顶点数据，而 3D 绘图用三维定点数据。

另外，3D 绘图时，我们除了构造顶点数据之外，还可以构造其他的数据，比较有用的是法向量。法向量是垂直于物体表面三角网格的向量，使用它可以来计算光照。在片元着色器中我们拿到的是经过模型矩阵变换后的顶点，使用法向量，我们还需要用一个法向量矩阵对它进行变换。法向量矩阵是模型矩阵的逆转置矩阵，它是一个 3X3 的矩阵，将法向量经过法向量矩阵变换后，我们就可以和片元着色器中的顶点进行运算了。

小试牛刀

在今天的课程中，我们绘制出了正立方体。那你能修改例子中的 cube 函数，构造出非正立方体吗？新的 cube 函数签名如下：

 function cube(width = 1.0, height = 1.0, depth = 1.0, colors = [[1, 0, 0, 1]])

你能用我们今天讲的方法绘制出一个正四面体，并给不同的面设置不同的颜色，然后在正四面体上实现点光源光照效果吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程中完整示例代码见 GitHub 仓库

21 | 如何添加相机，用透视原理对物体进行投影？

你好，我是月影。

上节课，我们在绘制 3D 几何体的时候，实际上有一个假设，那就是观察者始终从三维空间坐标系的正面，也就是 z 轴正方向，看向坐标原点。但在真实世界的模型里，观察者可以处在任何一个位置上。

那今天，我们就在上节课的基础上，引入一个空间观察者的角色，或者说是相机（Camera），来总结一个更通用的绘图模型。这样，我们就能绘制出，从三维空间中任意一个位置观察物体的效果了。

首先，我们来说说什么是相机。

如何理解相机和视图矩阵？

我们现在假设，在 WebGL 的三维世界任意位置上有一个相机，它可以用一个三维坐标（Position）和一个三维向量方向（LookAt Target）来表示。

在初始情况下，相机的参考坐标和世界坐标是重合的。但是，当我们移动或者旋转相机的时候，相机的参考坐标和世界坐标就不重合了。

而我们最终要在 Canvas 画布上绘制出的是，以相机为观察者的图形，所以我们就需要用一个变换，将世界坐标转换为相机坐标。这个变换的矩阵就是视图矩阵（ViewMatrix）。

计算视图矩阵比较简单的一种方法是，我们先计算相机的模型矩阵，然后对矩阵使用 lookAt 函数，这样我们得到的矩阵就是视图矩阵的逆矩阵。然后，我们再对这个逆矩阵求一次逆，就可以得到视图矩阵了。

这么说还是有点比较抽象，我们通过代码来理解。

function updateCamera(eye, target = [0, 0, 0]) {
  const [x, y, z] = eye;
  const m = new Mat4(
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    x, y, z, 1,
  );
  const up = [0, 1, 0];
  m.lookAt(eye, target, up).inverse();
  renderer.uniforms.viewMatrix = m;
}

如上面代码所示，我们设置相机初始位置矩阵 m，然后执行 m.lookAt(eye, target, up)，这里的 up 是一个向量，表示朝上的方向，我们把它定义为 y 轴正向。然后我们调用 inverse，将这个结果求逆，得到的就是视图矩阵。

为了让你看到相机的效果，我们改写上节课圆柱体的顶点着色器代码，加入视图矩阵。

 attribute vec3 a_vertexPosition;
  attribute vec4 color;
  attribute vec3 normal;

  varying vec4 vColor;
  varying float vCos;
  uniform mat4 projectionMatrix;
  uniform mat4 modelMatrix;
  uniform mat4 viewMatrix;
  uniform mat3 normalMatrix;

  const vec3 lightPosition = vec3(1, 0, 0);

  void main() {
    gl_PointSize = 1.0;
    vColor = color;
    vec4 pos = viewMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);
    vec4 lp = viewMatrix * vec4(lightPosition, 1.0);
    vec3 invLight = lp.xyz - pos.xyz;
    vec3 norm = normalize(normalMatrix * normal);
    vCos = max(dot(normalize(invLight), norm), 0.0);
    gl_Position = projectionMatrix * pos;
  }

这样，如果我们就把相机位置改变了。我们以 updateCamera([0.5, 0, 0.5]); 为例，这样朝向 (0, 0, 0) 拍摄图像的最终效果就如下所示。

剪裁空间和投影对 3D 图像的影响

在前面的课程中我们说过，WebGL 的默认坐标范围是从 -1 到 1 的。也就是说，只有当图像的 x、y、z 的值在 -1 到 1 区间内才会被显示在画布上，而在其他位置上的图像都会被剪裁掉。

举个例子，如果我们修改模型矩阵，让圆柱体沿 x、y 轴平移，向右上方各平移 0.5，那么圆柱中 x、y 值大于 1 的部分都会被剪裁掉，因为这些部分已经超过了 Canvas 边缘。操作代码和最终效果如下：

function update() {
  const modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
  modelMatrix[12] = 0.5; // 给 x 轴增加 0.5 的平移
  modelMatrix[13] = 0.5; // 给 y 轴也增加 0.5 的平移
  renderer.uniforms.modelMatrix = modelMatrix;
  renderer.uniforms.normalMatrix = normalFromMat4([], modelMatrix);
  ...
}

给x、y增加0.5平移后的效果

对于只有 x、y 的二维坐标系来说，这一点很好理解。但是，对于三维坐标系来说，不仅 x、y 轴会被剪裁，z 轴同样也会被剪裁。我们还是直接修改代码，给 z 轴增加 0.5 的平移。你会看到，最终绘制出来的图形非常奇怪。

给z轴增加0.5平移后的效果

会显示这么奇怪的结果，就是因为 z 轴超过范围的部分也被剪裁掉了，导致投影出现了问题。

既然是投影出现了问题，我们先回想一下，我们都对 z 轴做过哪些投影操作。在绘制圆柱体的时候，我们只是用投影矩阵非常简单地反转了一下 z 轴，除此之外，没做过其他任何操作了。所以，为了让图形在剪裁空间中正确显示，我们不能只反转 z 轴，还需要将图像从三维空间中投影到剪裁坐标内。那么问题来了，图像是怎么被投影到剪裁坐标内的呢？

一般来说，投影有两种方式，分别是正投影与透视投影。你可以结合我给出的示意图，来理解它们各自的特点。

首先是正投影，它又叫做平行投影。正投影是将物体投影到一个长方体的空间（又称为视景体），并且无论相机与物体距离多远，投影的大小都不变。

正投影示意图

而透视投影则更接近我们的视觉感知。它投影的规律是，离相机近的物体大，离相机远的物体小。与正投影不同，正投影的视景体是一个长方体，而透视投影的视景体是一个棱台。

透视投影示意图

知道了不同投影方式的特点，我们就可以根据投影方式和给定的参数来计算投影矩阵了。因为数学推导过程比较复杂，我在这里就不详细推导了，直接给出对应的 JavaScript 函数，你只要记住 ortho 和 perspective 这两个投影函数就可以了，函数如下所示。

其中，ortho 是计算正投影的函数，它的参数是视景体 x、y、z 三个方向的坐标范围，它的返回值就是投影矩阵。而 perspective 是计算透视投影的函数，它的参数是近景平面 near、远景平面 far、视角 fov 和宽高比率 aspect，返回值也是投影矩阵。

// 计算正投影矩阵
function ortho(out, left, right, bottom, top, near, far) {
   let lr = 1 / (left - right);
   let bt = 1 / (bottom - top);
   let nf = 1 / (near - far);
   out[0] = -2 * lr;
   out[1] = 0;
   out[2] = 0;
   out[3] = 0;
   out[4] = 0;
   out[5] = -2 * bt;
   out[6] = 0;
   out[7] = 0;
   out[8] = 0;
   out[9] = 0;
   out[10] = 2 * nf;
   out[11] = 0;
   out[12] = (left + right) * lr;
   out[13] = (top + bottom) * bt;
   out[14] = (far + near) * nf;
   out[15] = 1;
   return out;
}

// 计算透视投影矩阵
function perspective(out, fovy, aspect, near, far) {
   let f = 1.0 / Math.tan(fovy / 2);
   let nf = 1 / (near - far);
   out[0] = f / aspect;
   out[1] = 0;
   out[2] = 0;
   out[3] = 0;
   out[4] = 0;
   out[5] = f;
   out[6] = 0;
   out[7] = 0;
   out[8] = 0;
   out[9] = 0;
   out[10] = (far + near) * nf;
   out[11] = -1;
   out[12] = 0;
   out[13] = 0;
   out[14] = 2 * far * near * nf;
   out[15] = 0;
   return out;
}

接下来，我们先试试对圆柱体进行正投影。假设，在正投影的时候，我们让视景体三个方向的范围都是 (-2,2)。以刚才的相机位置为参照（任何一个位置观察都一样，不管物体在哪里，都是只有之前大小的一半。因为视景体范围增加了），我们绘制出来的圆柱体的大小只有之前的一半。这是因为我们通过投影变换将空间坐标范围增大了一倍。

import {ortho} from '../common/lib/math/functions/Mat4Func.js';
function projection(left, right, bottom, top, near, far) {
  return ortho([], left, right, bottom, top, near, far);
}

const projectionMatrix = projection(-2, 2, -2, 2, -2, 2);
renderer.uniforms.projectionMatrix = projectionMatrix; // 投影矩阵

updateCamera([0.5, 0, 0.5]); // 设置相机位置

接下来，我们再试一下对圆柱体进行透视投影。在进行透视投影的时候，我们将相机的位置放在 (2, 2, 3) 的地方。

import {perspective} from '../common/lib/math/functions/Mat4Func.js';

function projection(near = 0.1, far = 100, fov = 45, aspect = 1) {
  return perspective([], fov * Math.PI / 180, aspect, near, far);
}

const projectionMatrix = projection();
renderer.uniforms.projectionMatrix = projectionMatrix;

updateCamera([2, 2, 3]); // 设置相机位置

我们发现，在透视投影下，距离观察者（相机）近的部分大，距离它远的部分小。这更符合真实世界中我们看到的效果，所以一般来说，在绘制 3D 图形时，我们更偏向使用透视投影。

3D 绘图标准模型

实际上，通过上节课和刚才的内容，我们已经能总结出 3D 绘制几何体的基本数学模型，也就是 3D 绘图的标准模型。这个标准模型一共有四个矩阵，它们分别是：投影矩阵、视图矩阵（ViewMatrix）、模型矩阵（ModelMatrix）、法向量矩阵（NormalMatrix）。

其中，前三个矩阵用来计算最终显示的几何体的顶点位置，第四个矩阵用来实现光照等效果。比较成熟的图形库，如ThreeJS、BabylonJS，基本上都是采用这个标准模型来进行 3D 绘图的。所以理解这个模型，也有助于增强我们对图形库的认识，帮助我们更好地去使用这些流行的图形库。

在前面的课程中，因为 WebGL 原生的 API 在使用上比较复杂，所以我们使用了简易的 gl-renderer 库来简化 2D 绘图过程。而 3D 绘图是一个比 2D 绘图更加复杂的过程，即使是 gl-renderer 库也有点力不从心，我们需要更加强大的绘图库，来简化我们的绘制，以便于我们能够把精力专注于理解图形学本身的核心内容。

当然，使用 ThreeJS 或 BabeylonJS 都是不错的选择。但是在这节课中，我会使用一个更加轻量级的图形库，叫做OGL。它拥有我们可视化绘图需要的所有基本功能，而且，相比于 ThreeJS 等流行图形库，它的 AP 相对更底层、更简单一些。因此不会有太多高级的特性对我们的学习造成干扰。

接下来，我就用这个库来绘制一些简单的圆柱体、立方体等等，让你对这个库的使用有一个全面的了解。

如何使用 OGL 绘制基本的几何体

OGL 库使用的也是我们刚才说的标准模型，因此，使用它所以绘制几何体非常简单，分成以下 7 个步骤，如下图所示。

接下来，我们详细来看看每一步的操作。

首先，是创建 Renderer 对象。我们可以创建一个画布宽高为 512 的 Renderer 对象。代码如下：

const canvas = document.querySelector('canvas');
const renderer = new Renderer({
  canvas,
  width: 512,
  height: 512,
});

然后，我们在 OGL 中，通过 new Camera 来创建相机，默认创建出的是透视投影相机。这里我们把视角设置为 35 度，位置设置为 (0,1,7)，朝向为 (0,0,0)。代码如下：

const gl = renderer.gl;
gl.clearColor(1, 1, 1, 1);
const camera = new Camera(gl, {fov: 35});
camera.position.set(0, 1, 7);
camera.lookAt([0, 0, 0]);

接着，我们创建场景。OGL 使用树形渲染的方式，所以在用 OGL 创建场景时，我们要使用 Transform 元素。Transform 类型是基本元素，它可以添加子元素和设置几何变换，如果父元素设置了变换，这些变换也会被应用到子元素。

const scene = new Transform();

然后，我们创建几何体对象。OGL 内置了许多常用的几何体对象，包括球体 Sphere、立方体 Box、柱 / 锥体 Cylinder 以及环面 Torus 等等。使用这些对象，我们可以快速创建这些几何体的顶点信息。那在这里，我创建了 4 个几何体对象，分别是球体、立方体、椎体和环面。

const sphereGeometry = new Sphere(gl);
const cubeGeometry = new Box(gl);
const cylinderGeometry = new Cylinder(gl);
const torusGeometry = new Torus(gl);

再然后，我们创建 WebGL 程序。并且，我们在着色器中给这些几何体设置了浅蓝色和简单的光照效果。

const vertex = /* glsl */ `
  precision highp float;

  attribute vec3 position;
  attribute vec3 normal;
  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;
  varying vec3 vNormal;
  void main() {
      vNormal = normalize(normalMatrix * normal);
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = /* glsl */ `
  precision highp float;

  varying vec3 vNormal;
  void main() {
      vec3 normal = normalize(vNormal);
      float lighting = dot(normal, normalize(vec3(-0.3, 0.8, 0.6)));
      gl_FragColor.rgb = vec3(0.2, 0.8, 1.0) + lighting * 0.1;
      gl_FragColor.a = 1.0;
  }
`;

const program = new Program(gl, {
  vertex,
  fragment,
});

有了 WebGL 程序之后，我们使用它和几何体对象来构建真正的网格（Mesh）元素，最终再把这些元素渲染到画布上。我们创建了 4 个网格对象，它们的形状分别是环面、球体、立方体和圆柱，我们给它们设置了不同的位置，然后将它们添加到场景 scene 中去。

const torus = new Mesh(gl, {geometry: torusGeometry, program});
torus.position.set(0, 1.3, 0);
torus.setParent(scene);

const sphere = new Mesh(gl, {geometry: sphereGeometry, program});
sphere.position.set(1.3, 0, 0);
sphere.setParent(scene);

const cube = new Mesh(gl, {geometry: cubeGeometry, program});
cube.position.set(0, -1.3, 0);
cube.setParent(scene);

const cylinder = new Mesh(gl, {geometry: cylinderGeometry, program});
cylinder.position.set(-1.3, 0, 0);
cylinder.setParent(scene);

最后，我们将它们用相机 camera 对象的设定渲染出来，并分别设置绕 y 轴旋转的动画，你就能看到这 4 个图像旋转的画面了。代码如下：

requestAnimationFrame(update);
function update() {
  requestAnimationFrame(update);

  torus.rotation.y -= 0.02;
  sphere.rotation.y -= 0.03;
  cube.rotation.y -= 0.04;
  cylinder.rotation.y -= 0.02;

  renderer.render({scene, camera});
}

要点总结

在这一节课，我们在三维空间里，引入了相机和视图矩阵的概念，相机分为透视相机和正交相机，它们有不同的投影方式，并且设置它们还可以改变剪裁空间。视图矩阵和前一节课介绍的投影矩阵、模型矩阵、法向量矩阵一起，构成了 3D 绘图标准模型，这是一般的图形库遵循的标准绘图方式。

为了巩固学习到的知识，我们使用 OGL 库来尝试绘制不同的 3D 几何体，我们依次用 OGL 绘制了球体、立方体、圆柱体和环面。OGL 绘制图形的基本步骤可以总结为 7 步，如下图：

小试牛刀

在上面的例子里，使用 OGL 绘制的球体看起来不是很圆，你可以研究一下OGL 的代码，修改一下创建球体的参数，让它看起来更圆。

你能试着修改一下片元着色器，让上面绘制的 4 个几何体呈现不同的颜色吗？将它们分别改成红色、黄色、蓝色和绿色。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程中完整示例代码详见GitHub 仓库

推荐阅读

OGL

22 | 如何用仿射变换来移动和旋转3D物体？

你好，我是月影。

在前面的课程里，我们学习过使用仿射变换来移动和旋转二维图形。那在三维世界中，想要移动和旋转物体，我们也需要使用仿射变换。

但是，仿射变换该怎么从二维扩展到三维几何空间呢？今天，我们就来看一下三维仿射变换的基本方法，以及怎么对它进行优化。

三维仿射变换和二维仿射变换类似，也包括平移、旋转与缩放等等，而且具体的变换公式也相似。

比如，对于平移变换来说，如果向量 P(x0​,y0​,z0​) 沿着向量 Q(x1​,y1​,z1​) 平移，我们只需要让 P 加上 Q，就能得到变换后的坐标。

⎩⎨⎧​x=x0​+x1​y=y0​+y1​z=z0​+z1​​

再比如，对于缩放变换来说，我们直接让三维向量乘上标量，就相当于乘上要缩放的倍数就可以了。最后我们得到的三维缩放变换矩阵如下：

M=⎣⎡​sx​00​0sy​0​00sz​​⎦⎤​

而且，我们也可以使用齐次矩阵来表示三维仿射变换，通过引入一个新的维度，就可以把仿射变换转换为齐次矩阵的线性变换了。

M’=[M0​01​]

这个齐次矩阵，是一个 4X4 的矩阵，其实它就是我们在第 19 节课提到的模型矩阵（ModelMatrix）。

总之，对于三维的仿射变换来说，平移和缩放都只是增加一个 z 分量，这和二维放射变换没有什么不同。但对于物体的旋转变换，三维就要比二维稍微复杂一些了。因为二维旋转只有一个参考轴，就是 z 轴，所以二维图形旋转都是围绕着 z 轴的。但是，三维物体的旋转却可以围绕 x、y、z，这三个轴其中任意一个轴来旋转。

因此，这节课，我们就把重点放在处理三维物体的旋转变换上。

使用欧拉角来旋转几何体

我们先来看一下三维物体的旋转变换矩阵：

绕y轴旋转：Ry​=⎣⎡​cosα 0 −sinα​010​sinα0cosα​⎦⎤​

绕x轴旋转：Rx​=⎣⎡​1 0 0​0cosβsinβ​0−sinβcosβ​⎦⎤​

绕z轴旋转：Rz​=⎣⎡​cosγ sinγ 0​−sinγcosγ0​001​⎦⎤​

你会看到，我们使用了三个旋转矩阵 Ry、Rx、Rz 来描述三维的旋转变换。这三个旋转矩阵分别表示几何体绕 y 轴、x 轴、z 轴转过 α、β、γ 角。而这三个角，就叫做欧拉角。

什么是欧拉角？

那什么是欧拉角呢？欧拉角是描述三维物体在空间中取向的标准数学模型，也是航空航天普遍采用的标准。对于在三维空间里的一个参考系，任何坐标系的取向，都可以用三个欧拉角来表示。

举个例子，下图中这个飞机的飞行姿态，可以由绕 x 轴的旋转角度（翻滚机身）、绕 y 轴的旋转角度（俯仰），以及绕 z 轴的旋转角度（偏航）来表示。

也就是说，这个飞机的姿态可以由这三个欧拉角来确定。具体的表示公式就是 Rx、Ry、Rz，这三个旋转矩阵相乘。

M=Ry​×Rx​×Rz​

这里，我们是按照 Ry、Rx、Rz 的顺序相乘的。而 y−x−z 顺序有一个专属的名字叫做欧拉角的顺规，也就是说，我们现在采用的是 y−x−z 顺规。欧拉角有很多种不同的顺规表示方式，一共可以分两种：一种叫做 Proper Euler angles，包含六种顺规，分别是 z−x−z、x−y−x、y−z−y、z−y−z、x−z−x、y−x−y；另一种叫做 Tait–Bryan angles，也包含六种顺规，分别是 x−y−z、y−z−x、z−x−y、x−z−y、z−y−x、y−x−z。

显然，我们采用的 y−x−z 顺规，属于 Tait–Bryan angles。

不同的欧拉角顺规虽然表示方法不同，但它们本质上还是欧拉角，都可以表示三维几何空间中的任意取向。所以，我们在绘制三维图形的时候，使用任何一种表示法都可以。今天，我就以 y−x−z 顺规为例来接着讲。

采用 y−x−z 顺规的欧拉角之后，我们能得到如下的旋转矩阵结果：

如何使用欧拉角来旋转几何体？

接下来，我们通过一个例子来实际体会，使用欧拉角旋转几何体的具体过程。

这里，我们还是用 OGL 框架。OGL 的几何网格（Mesh）对象直接支持欧拉角，我们直接用对象的 rotation 属性就可以设置欧拉角，rotation 属性是一个三维向量，它的 x、y、z 坐标就对应围绕 x、y、z 旋转的欧拉角。而且 OGL 框架默认的欧拉角顺规是 y−x−z。

为了增加趣味性，我们不用立方体、圆柱体这些一般几何体，而是旋转一个飞机的几何模型。

在 OGL 中，我们可以加载 JSON 文件，来载入预先设计好的几何模型。

下面就是我先封装好的，一个加载几何模型的函数。这个函数会载入 JSON 文件的内容，然后根据其中的数据创建 Geometry 对象，并返回这个对象。

async function loadModel(src) {
  const data = await (await fetch(src)).json();

  const geometry = new Geometry(gl, {
    position: {size: 3, data: new Float32Array(data.position)},
    uv: {size: 2, data: new Float32Array(data.uv)},
    normal: {size: 3, data: new Float32Array(data.normal)},
  });

  return geometry;
}

这样，我们通过如下指令，就可以加载飞机几何体模型了。

const geometry = await loadModel('../assets/airplane.json');

这里的 assets/airplane.json 是一份几何模型文件，内容类似于下面这样：

{
  "position": [0.752, 1.061, 0.0, 0.767...],
  "normal": [0.975, 0.224, 0.0, 0.975...],
  "uv": [0.745, 0.782, 0.705, 0.769...]
}

其中 position、normal、uv 是顶点数据，我们比较熟悉，分别是顶点坐标、法向量和纹理坐标。这样的数据一般是由设计工具直接生成的，不需要我们来计算。

接下来，我们加载飞机的纹理图片，同样要先封装一个加载图片纹理的函数。在函数里，我们用 img 元素加载图片，然后将图片赋给对应的纹理对象。函数代码如下：

function loadTexture(src) {
  const texture = new Texture(gl);
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      texture.image = img;
      resolve(texture);
    };
    img.src = src;
  });
}

接着，我们就可以加载飞机的纹理图片了。具体操作如下：

const texture = await loadTexture('../assets/airplane.jpg');

然后，我们在片元着色器中，直接读取纹理图片中的颜色信息：

precision highp float;

uniform sampler2D tMap;
varying vec2 vUv;

void main() {
  gl_FragColor = texture2D(tMap, vUv);
}

最后，我们就能将元素渲染出来了。渲染指令如下：

const program = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    tMap: {value: texture},
  },
});
const mesh = new Mesh(gl, {geometry, program});
mesh.setParent(scene);
renderer.render({scene, camera});

最终，我们就能得到可以随意调整欧拉角的飞机模型了，效果如下图所示：

如何理解万向节锁？

使用欧拉角来操作几何体的方向，虽然很简单，但是有一个小缺陷，这个缺陷叫做万向节锁 (Gimbal Lock)。那万向节锁是什么呢，我们通过上面的例子来解释。

你会发现，当我们分别改变飞机的 alpha、beta、theta 值时，飞机会做出对应的姿态调整，包括偏航（改变 alpha）、翻滚（改变 beta）和俯仰（改变 theta）。

但是如果我们将 beta 固定在正负 90 度，改变 alpha 和 beta，我们会发现一个奇特的现象：

如上图所示，我们将 beta 设为 90 度，不管改变 alpha 还是改变 theta，飞机都绕着 y 轴旋转，始终处于一个平面上。也就是说，本来飞机姿态有 x、y、z 三个自由度，现在 y 轴被固定了，只剩下两个自由度了，这就是万向节锁。

万向节锁，并不是真的“锁”住。而是在特定的欧拉角情况下，姿态调整的自由度丢失了。而且，只要是欧拉角，不管我们使用哪一种顺规，万向节锁都会存在。这该怎么解决呢？

要避免万向节锁的产生，我们只能使用其他的数学模型，来代替欧拉角描述几何体的旋转。其中一个比较好的模型是四元数（Quaternion）。

使用四元数来旋转几何体

四元数是一种高阶复数，一个四元数可以表示为：q=w+xi+yj+zk。其中，i、j、k 是三个虚数单位，w 是标量，它们满足 i2=j2=k2=ijk=−1。如果我们把 xi+yj+zk 看成是一个向量，那么四元数 q 又可以表示为 q=(v,w)，其中 v 是一个三维向量。

我们可以用单位四元数来描述 3D 旋转。所谓单位四元数，就是其中的参数满足 x2+y2+z2+w2=1。单位四元数对应的旋转矩阵如下：

R(q)=⎣⎡​1−2y2−2z22xy+2zw2xz−2yw​2xy−2zw1−2x2−2z22yz+2xw​2xz+2yw2yz−2xw1−2x2−2y2​⎦⎤​

这个旋转矩阵的数学推导过程比较复杂，我们只要记住这个公式就行了。

与欧拉角相比，四元数没有万向节死锁的问题。而且与旋转矩阵相比，四元数只需要四个分量就可以定义，模型上更加简洁。但是，四元数相对来说没有旋转矩阵和欧拉角那么直观。

四元数与轴角

四元数有一个常见的用途是用来处理轴角。所谓轴角，就是在三维空间中，给定一个由单位向量表示的轴，以及一个旋转角度 ⍺，以此来表示几何体绕该轴旋转 ⍺ 角。

轴角

绕单位向量 u 旋转 ⍺ 角，对应的四元数可以表示为：q=(usin(⍺/2),cos(⍺/2))。接着，我们来看一个四元数处理轴角的例子。

还是以前面飞机为例，不过，这次我们将欧拉角换成轴角，实现一个 updateAxis 和 updateQuaternion 函数，分别更新轴和四元数。

// 更新轴
function updateAxis() {
  const {x, y, z} = palette;
  const v = new Vec3(x, y, z).normalize().scale(10);
  points[1].copy(v);
  axis.updateGeometry();
  renderer.render({scene, camera});
}

// 更新四元数
function updateQuaternion(val) {
  const theta = 0.5 * val / 180 * Math.PI;
  const c = Math.cos(theta);
  const s = Math.sin(theta);
  const p = new Vec3().copy(points[1]).normalize();
  const q = new Quat(p.x * s, p.y * s, p.z * s, c);
  mesh.quaternion = q;
  renderer.render({scene, camera});
}

然后，我们定义轴， 再把它显示出来。在 OGL 里面，我们可以通过 Polyline 对象来绘制轴。代码如下：

const points = [
  new Vec3(0, 0, 0),
  new Vec3(0, 10, 0),
];

const axis = new Polyline(gl, {
  points,
  uniforms: {
    uColor: {value: new Color('#f00')},
    uThickness: {value: 3},
  },
});
axis.mesh.setParent(scene);

那么，随着我们修改轴或者修改旋转角，物体就会绕着轴旋转。效果如下图所示：

这样，我们就实现了用四元数让飞机沿着某个轴旋转的效果了。这其中最重要的一步，是要你理解怎么根据旋转轴和轴角来计算对应的四元数，也就是 updateQuaternion 函数里面做的事情。然后我们将这个更新后的四元数赋给飞机的 mesh 对象，就可以更新飞机的位置，实现飞机绕轴的旋转。我只在课程中给出了关键部分的代码，你可以去 GitHub 仓库里找到对应例子的完整代码。

要点总结

今天，我们学习了使用三维仿射变换，来移动和旋转 3D 物体。三维仿射变换在平移和缩放变换上的绘制方法，与二维仿射变换类似，只不过增加了一个 z 维度。但是对于旋转变换，三维放射变换就要复杂一些了，因为 3D 物体可以绕 x、y、z 轴中任意一个方向旋转。

那想要旋转三维几何体，我们可以使用欧拉角。欧拉角实际上就等于，绕 x、y、z 三个轴方向的旋转矩阵相乘，相乘的顺序就是欧拉角的顺规。

虽然顺规有很多种，但是选择不同的顺规，只是表达方式不一样，最终结果是等价的，都是欧拉角。那在这节课中，我们采用 y−x−z 顺规，它也是 OGL 库默认采用的。

但是欧拉角有一个万向节锁的问题，就是当 β 角旋转到正负 90 度的时候，我们无论怎么改变 α、γ 角，都只能让物体在一个水平面上运动。而且，只要我们使用欧拉角，就无法避免万向节锁的出现。

为了避免万向节锁，我们可以用四元数来旋转几何体。除此之外，四元数还有一个作用是可以用来构造轴角，让物体沿着某个具体的轴旋转。你可以回想一下我们刚刚实现的绕轴飞行的飞机。

小试牛刀

你可以试着利用放射变换，来实现一个旋转的 3D 陀螺效果。陀螺的形状可以用一个简单的圆锥体来表示。旋转的过程中，你可以让陀螺绕自身的中间轴旋转，也可以让它绕着三维空间某个固定的轴旋转。快来动手试一试吧。效果如下：

除了旋转的飞机和旋转的陀螺，你还能实现哪些旋转的物体呢？不如也把这篇文章分享给你的朋友们，一起来实现一下吧！

源码

课程中详细示例代码GitHub 仓库

推荐阅读

[1] 进一步理解欧拉角

[2] 欧拉角的不同表示方法参考文档

[3] 四元数与三维旋转

[4] 三维旋转：欧拉角、四元数、旋转矩阵、轴角之间的转换

23 | 如何模拟光照让3D场景更逼真？（上）

你好，我是月影。

在我们生活的真实物理世界中，充满了各种类型的光。在这些光的照射下，我们看到的每个物体都会呈现不同的色彩。所以，要想让我们渲染出的 3D 物体看起来更自然、逼真，很重要的一点就是模拟各种光照的效果。那今天，我们就一起来学习一下，怎么模拟光照效果。

物体的光照效果是由光源、介质（物体的材质）和反射类型决定的，而反射类型又由物体的材质特点决定。在 3D 光照模型中，根据不同的光源特点，我们可以将光源分为 4 种不同的类型，分别是环境光（Ambient Light）、平行光（Directional Light）、点光源（Positional Light）和聚光灯（Spot Light）。而物体的反射类型，则分为漫反射和镜面反射两种。

当然了，实际自然界中的光照效果，肯定比我说的要复杂得多。但现阶段，我们弄明白这三个决定因素，就能模拟出非常真实的光照效果了。

如何给物体增加环境光效果？

我们先来说说怎么给物体增加环境光效果。

那什么是环境光呢？环境光就是指物体所在的三维空间中天然的光，它充满整个空间，在每一处的光照强度都一样。环境光没有方向，所以，物体表面反射环境光的效果，只和环境光本身以及材质的反射率有关。

物体在环境光中呈现的颜色，我们可以利用下面的公式来求。其中，环境光的颜色为 L，材质对光的反射率为 R。

C=LR=⎣⎡​Lr​×Rr​Lg​×Rg​Lb​×Rb​​⎦⎤​

接着，我们创建一个片元着色器，代码如下：

precision highp float;

uniform vec3 ambientLight;
uniform vec3 materialReflection;

void main() {
  gl_FragColor.rgb = ambientLight * materialReflection;
  gl_FragColor.a = 1.0;
}

我们用这个着色器创建WebGL 着色器程序，传入环境光 ambientLight 和材质反射率 materialReflection，就可以渲染出各种颜色的几何体了。

那你可能有疑问了，通过这样渲染出来的几何体颜色，与我们之前通过设置颜色属性得到的颜色有什么区别呢？

在前面的课程里，我们绘制的几何体只有颜色属性，但是在光照模型里，我们把颜色变为了环境光和反射率两个属性。这样的模型更加接近于真实世界，也让物体的颜色有了更灵活的控制手段。比如，我们修改环境光，就可以改变整个画布上所有受光照模型影响的几何体的颜色，而如果只是像之前那样给物体分别设置颜色，我们就只能一一修改这些物体各自的颜色了。

最后，我希望你能记住环境光的两个特点。

首先，因为它在空间中均匀分布，所以在任何位置上环境光的颜色都相同。其次，它与物体的材质有关。如果物体的 RGB 通道反射率不同的话，那么它在相同的环境光下就会呈现出不同的颜色。因此，如果环境光是白光（#FFF），那么物体呈现的颜色就是材质反射率表现出的颜色，也就是物体的固有颜色。

如何给物体增加平行光效果？

除了环境光以外，平行光也很常见。与环境光不同，平行光是朝着某个方向照射的光，它能够照亮几何体的一部分表面。

平行光示意图

而且，平行光除了颜色这个属性之外，还有方向，它属于有向光。有向光在与物体发生作用的时候，根据物体的材质特性，会产生两种反射，一种叫做漫反射（Diffuse reflection），另一种叫做镜面反射（Specular reflection），而一个物体最终的光照效果，是漫反射、镜面反射以及我们前面说的环境光叠加在一起的效果。因为内容比较多，所以这节课，我们先来讨论光源的漫反射效果。下节课，我们再继续讨论光源的镜面反射，以及多个光源混合的反射效果。如下图所示：

多种反射叠加的效果示意图

有向光的漫反射在各个方向上的反射光均匀分布，反射强度与光的射入方向与法线的夹角的余弦成正比。

那我们该如何让 3D 物体呈现出，平行光照射下的颜色效果呢？下面，我就以添加一道白色的平行光为例，来具体说说操作过程。

首先，我们在顶点着色器中添加一道平行光。具体来说就是传入一个 directionalLight 向量。为什么是顶点着色器呢？因为，我们在顶点着色器中计算光线的方向，需要运算的次数少，会比在片元着色器中计算的性能要好很多。

 precision highp float;

  attribute vec3 position;
  attribute vec3 normal;
  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat4 viewMatrix;
  uniform mat3 normalMatrix;
  uniform vec3 directionalLight;

  varying vec3 vNormal;
  varying vec3 vDir;

  void main() {
    // 计算光线方向
    vec4 invDirectional = viewMatrix * vec4(directionalLight, 0.0);
    vDir = -invDirectional.xyz;

    // 计算法向量
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }

然后，在片元着色器里，我们计算光线方向与法向量夹角的余弦，计算出漫反射光。在平行光下，物体最终呈现的颜色是环境光加上漫反射光与材质反射率的乘积。

precision highp float;

uniform vec3 ambientLight;
uniform vec3 materialReflection;
uniform vec3 directionalLightColor;

varying vec3 vNormal;
varying vec3 vDir;

void main() {
  // 求光线与法线夹角的余弦
  float cos = max(dot(normalize(vDir), vNormal), 0.0);

  // 计算漫反射
  vec3 diffuse = cos * directionalLightColor;

  // 合成颜色
  gl_FragColor.rgb = (ambientLight + diffuse) * materialReflection;
  gl_FragColor.a = 1.0;
}

接着，我们在 JavaScript 代码里，给 WebGL 程序添加一个水平向右的白色平行光，代码如下：

const ambientLight = {value: [0.5, 0.5, 0.5]};

const directional = {
  directionalLight: {value: [1, 0, 0]},
  directionalLightColor: {value: [1, 1, 1]},
};

const program1 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ambientLight,
    materialReflection: {value: [0, 0, 1]},
    ...directional,
  },
});
...

最终显示的效果如下图所示，当旋转相机位置的时候，我们看到物体因为光照，不同方向表面的明暗度不一样。

如何添加点光源？

除了平行光之外，点光源和聚光灯也都是有向光。

点光源顾名思义，就是指空间中某一点发出的光，与方向光不同的是，点光源不仅有方向属性，还有位置属性。因此计算点光源的光照，我们要先根据光源位置和物体表面相对位置来确定方向，然后再和平行光一样，计算光的方向和物体表面法向的夹角。计算过程要比平行光稍微复杂一些。

点光源与平行光

对于平行光来说，只要法向量相同，方向就相同，所以我们可以直接在顶点着色器中计算方向。但点光源因为其方向与物体表面的相对位置有关，所以我们不能在顶点着色器中计算，需要在片元着色器中计算。

因此，计算点光源光照效果的第一步，就是要在顶点着色器中，将物体变换后的坐标传给片元着色器，代码如下：

precision highp float;

attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;

varying vec3 vNormal;
varying vec3 vPos;

void main() {
  vPos = modelViewMatrix * vec4(position, 1.0);;
  vNormal = normalize(normalMatrix * normal);
  gl_Position = projectionMatrix * vPos;
}

那接下来，片元着色器中的计算过程就和平行光类似了。 我们要计算光线方向与法向量夹角的余弦，我们用 (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos 得出点光源与当前位置的向量，然后用这个向量和法向量计算余弦值，这样就得到了我们需要的漫反射余弦值。对应的片元着色器如下：

precision highp float;

uniform vec3 ambientLight;
uniform vec3 materialReflection;
uniform vec3 pointLightColor;
uniform vec3 pointLightPosition;
uniform mat4 viewMatrix;

varying vec3 vNormal;
varying vec3 vPos;

void main() {
  // 光线到点坐标的方向
  vec3 dir = (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos;

  // 与法线夹角余弦
  float cos = max(dot(normalize(dir), vNormal), 0.0);

  // 计算漫反射
  vec3 diffuse = cos * pointLightColor;

  // 合成颜色
  gl_FragColor.rgb = (ambientLight + diffuse) * materialReflection;
  gl_FragColor.a = 1.0;
}

假设我们将点光源设置在 (3,3,0) 位置，颜色为白光，得到的效果如下图所示。

点光源的衰减

但是，前面的计算过程都是理想状态下的。而真实世界中，点光源的光照强度会随着空间的距离增加而衰减。所以，为了实现更逼真的效果，我们必须要把光线衰减程度也考虑进去。光线的衰减程度，我们一般用衰减系数表示。衰减系数等于一个常量 d0​（通常为 1），除以衰减函数 p。

一般来说，衰减函数可以用一个二次多项式 P 来描述，它的计算公式为：

{P=Az2+Bz+Cd=Pd0​​​

其中 A、B、C 为常量，它们的取值会根据实际的需要随时变化，z 是当前位置到点光源的距离。

接下来，我们需要在片元着色器中增加衰减系数。在计算的时候，我们必须要提供光线到点坐标的距离。具体的操作代码如下：

precision highp float;

uniform vec3 ambientLight;
uniform vec3 materialReflection;
uniform vec3 pointLightColor;
uniform vec3 pointLightPosition;
uniform mat4 viewMatrix;
uniform vec3 pointLightDecayFactor;

varying vec3 vNormal;
varying vec3 vPos;

void main() {
  // 光线到点坐标的方向
  vec3 dir = (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos;

  // 光线到点坐标的距离，用来计算衰减
  float dis = length(dir);

  // 与法线夹角余弦
  float cos = max(dot(normalize(dir), vNormal), 0.0);

  // 计算衰减
  float decay = min(1.0, 1.0 /
    (pointLightDecayFactor.x * pow(dis, 2.0) + pointLightDecayFactor.y * dis + pointLightDecayFactor.z));

  // 计算漫反射
  vec3 diffuse = decay * cos * pointLightColor;

  // 合成颜色
  gl_FragColor.rgb = (ambientLight + diffuse) * materialReflection;
  gl_FragColor.a = 1.0;
}

假设，我们将衰减系数设置为 (0.05, 0, 1)，就能得到如下效果。把它和前一张图对比，你会发现，我们看到较远的几何体几乎没有光照了。这就是因为光线强度随着距离衰减了，也就更接近真实世界的效果。

如何给物体添加聚光灯效果？

最后，我们再来说说，怎么给物体添加聚光灯效果。

聚光灯示意图

与点光源相比，聚光灯增加了方向以及角度范围，只有在这个范围内，光线才能照到。那该如何判断坐标是否在角度范围内呢？我们可以根据法向量与光线方向夹角的余弦值来判断坐标是否在夹角内，还记得我们在第 6 节课一开始就讨论的那道题目吗，这里就是具体应用。

所以，最终片元着色器中的代码如下：

precision highp float;

uniform mat4 viewMatrix;
uniform vec3 ambientLight;
uniform vec3 materialReflection;
uniform vec3 spotLightColor;
uniform vec3 spotLightPosition;
uniform vec3 spotLightDecayFactor;
uniform vec3 spotLightDirection;
uniform float spotLightAngle;

varying vec3 vNormal;
varying vec3 vPos;

void main() {
  // 光线到点坐标的方向
  vec3 invLight = (viewMatrix * vec4(spotLightPosition, 1.0)).xyz - vPos;
  vec3 invNormal = normalize(invLight);

  // 光线到点坐标的距离，用来计算衰减
  float dis = length(invLight);
  // 聚光灯的朝向
  vec3 dir = (viewMatrix * vec4(spotLightDirection, 0.0)).xyz;

  // 通过余弦值判断夹角范围
  float ang = cos(spotLightAngle);
  float r = step(ang, dot(invNormal, normalize(-dir)));

  // 与法线夹角余弦
  float cos = max(dot(invNormal, vNormal), 0.0);
  // 计算衰减
  float decay = min(1.0, 1.0 /
    (spotLightDecayFactor.x * pow(dis, 2.0) + spotLightDecayFactor.y * dis + spotLightDecayFactor.z));

  // 计算漫反射
  vec3 diffuse = r * decay * cos * spotLightColor;

  // 合成颜色
  gl_FragColor.rgb = (ambientLight + diffuse) * materialReflection;
  gl_FragColor.a = 1.0;
}

如上面代码所示，聚光灯相对来说比较复杂，我们要用整整 5 个参数来描述，它们分别是：

spotLightColor 聚光灯颜色

spotLightPosition 聚光灯位置

spotLightDecayFactor 聚光灯衰减系数

spotLightDirection 聚光灯方向

spotLightAngle 聚光灯角度

在计算光线和法线夹角的余弦值时，我们是用与点光源一样的方式。此外，我们还增加了一个步骤，就是以聚光灯方向和角度，计算点坐标是否在光照角度内。如果在，那么 r 的值是 1，否则 r 的值是 0。

假设我们是这样设置的，那么最终的光照效果就只会出现在光照的角度内。

const directional = {
  spotLightPosition: {value: [3, 3, 0]},
  spotLightColor: {value: [1, 1, 1]},
  spotLightDecayFactor: {value: [0.05, 0, 1]},
  spotLightDirection: {value: [-1, -1, 0]},
  spotLightAngle: {value: Math.PI / 12},
};

我们最终渲染出来的结果如下图：

要点总结

在这一节课，我们主要讲了模拟真实世界中 4 种不同光源的方法，这四种不同光源分别是环境光、平行光、点光源和聚光灯。

其中，环境光比较简单，它充满整个环境空间，在空间每一处的强度都相同。环境光作用于物体材质，根据材质对光的反射率，让材质呈现出不同的颜色。

另外三种光是有向光，它们作用于物体表面的效果，除了与物体材质的反射率有关，还和表面的朝向有关，所以我们需要计算光线方向和表面法向量的余弦值，用它来计算反射强度。

这三种光当中，平行光只有方向和颜色两个参数，点光源有位置、颜色和衰减系数三个参数，而聚光灯更加复杂，有位置、方向、角度范围、颜色和衰减系数五个参数。我们在着色器中根据这些参数进行计算，最终就能得到物体被光照后的漫反射结果。

小试牛刀

你会发现，这节课，我举的都是单一光源的例子，也就是空间中除了环境光以外，只有一个光源。但在真实的世界里，空间中肯定不止一种光源。你能试着修改例子中的代码，添加多个光源，让它们共同作用于物体吗？会实现什么样的效果呢？

欢迎在留言区分享你的答案和思考，也希望你能把这节课的内容转发出去，我们下节课再见！

源码

本课中完整的示例代码见 GitHub 仓库

24 | 如何模拟光照让3D场景更逼真？（下）

你好，我是月影。今天，我们接着来讲，怎么模拟光照。

上节课，我们讲了四种光照的漫反射模型。实际上，因为物体的表面材质不同，反射光不仅有漫反射，还有镜面反射。

镜面反射与漫反射

什么是镜面反射呢？如果若干平行光照射在表面光滑的物体上，反射出来的光依然平行，这种反射就是镜面反射。镜面反射的性质是，入射光与法线的夹角等于反射光与法线的夹角。

越光滑的材质，它的镜面反射效果也就越强。最直接的表现就是物体表面会有闪耀的光斑，也叫镜面高光。但并不是所有光都能产生镜面反射，我们上节课讲的四种光源中，环境光因为没有方向，所以不参与镜面反射。剩下的平行光、点光源、聚光灯这三种光源，都是能够产生镜面反射的有向光。

镜面高光

那么今天，我们就来讨论一下如何实现镜面反射，然后将它和上节课的漫反射结合起来，就可以实现标准的光照模型，也就是 Phong 反射模型了，从而能让我们实现的可视化场景更加接近于自然界的效果。

如何实现有向光的镜面反射?

首先，镜面反射需要同时考虑光的入射方向以及相机也就是观察者所在的方向。

观察者与光的入射方向

接着，我们再来说说怎么实现镜面反射效果，一般来说需要 4 个步骤。

第一步，求出反射光线的方向向量。这里我们以点光源为例，要求出反射光的方向，我们可以直接使用 GLSL 的内置函数 reflect，这个函数能够返回一个向量相对于某个法向量的反射向量，正好就是我们要的镜面反射结果。

// 求光源与点坐标的方向向量
vec3 dir = (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos;

// 归一化
dir = normalize(dir);

// 求反射向量
vec3 reflectionLight = reflect(-dir, vNormal);

第二步，我们要根据相机位置计算视线与反射光线夹角的余弦，用到原理是向量的点乘。

vec3 eyeDirection = vCameraPos - vPos;
eyeDirection = normalize(eyeDirection);
// 与视线夹角余弦
float eyeCos = max(dot(eyeDirection, reflectionLight), 0.0);

第三步，我们使用系数和指数函数设置镜面反射强度。指数越大，镜面越聚焦，高光的光斑范围就越小。这里，我们指数取 50.0，系数取 2.0。系数能改变反射亮度，系数越大，反射的亮度就越高。

float specular = 2.0 *  pow(eyeCos, 50.0);

最后，我们将漫反射和镜面反射结合起来，就会让距离光源近的物体上，形成光斑。

// 合成颜色
gl_FragColor.rgb = specular + (ambientLight + diffuse) * materialReflection;
gl_FragColor.a = 1.0;

上面的代码是以点光源为例来实现的光斑，其实只要是有向光，都可以用同样的方法求出镜面反射，只不过对应的入射光方向计算有所不同，也就是着色器代码中的 dir 变量计算方式不一样。 你可以利用我上节课讲的内容，自己动手试试。

如何实现完整的 Phong 反射模型?

那在自然界中，除了环境光以外，其他每种光源在空间中都可以存在不止一个，而且因为几何体材质不同，物体表面也可能既出现漫反射，又出现镜面反射。

可能出现的情况这么多，分析和计算起来也会非常复杂。为了方便处理，我们可以把多种光源和不同材质结合起来，形成标准的反射模型，这一模型被称为Phong 反射模型。

Phong 反射模型的完整公式如下：

Ip​=ka​ia​+m∈ lights ∑​(kd​(L^m​⋅N^)im,d​+ks​(R^m​⋅V^)αim,s​)

公式里的 ka​、kd​ 和 ks​ 分别对应环境反射系数、漫反射系数和镜面反射系数。L^m​ 是入射光，N 是法向量，R^m​ 是反射光，V 是视线向量。i 是强度，漫反射和镜面反射的强度可考虑因为距离的衰减。⍺ 是和物体材质有关的常量，决定了镜面高光的范围。

根据上面的公式，我们把多个光照的计算结果相加，就能得到光照下几何体的最终颜色了。不过，这里的 Phong 反射模型实际上是真实物理世界光照的简化模型，因为它只考虑光源的光作用于物体，没有考虑各个物体之间的反射光。所以我们最终实现出的效果也只是自然界效果的一种近似，不过这种近似也高度符合真实情况了。

在一般的图形库或者图形框架中，会提供符合 Phong 反射模型的物体材质，比如 ThreeJS 中，就支持各种光源和反射材质。

下面，我们来实现一下完整的 Phong 反射模型。它可以帮助你对这个模型有更深入的理解，让你以后使用 ThreeJS 等其他图形库，也能够更加得心应手。整个过程分为三步：定义光源模型、定义几何体材质和实现着色器。

1. 定义光源模型

我们先来定义光源模型对象。环境光比较特殊，我们将它单独抽象出来，放在一个 ambientLight 的属性中，而其他的光源一共有 5 个属性与材质无关，我列了一张表放在了下面。

这样，我们就可以定义一个 Phong 类。这个类由一个环境光属性和其他三种光源的集合组合而成，表示一个可以添加和删除光源的对象。它的主要作用是添加和删除光源，并把光源的属性通过 uniforms 访问器属性转换成对应的 uniform 变量，主要的代码如下：

class Phong {
  constructor(ambientLight = [0.5, 0.5, 0.5]) {
    this.ambientLight = ambientLight;
    this.directionalLights = new Set();
    this.pointLights = new Set();
    this.spotLights = new Set();
  }

  addLight(light) {
    const {position, direction, color, decay, angle} = light;
    if(!position && !direction) throw new TypeError('invalid light');
    light.color = color || [1, 1, 1];
    if(!position) this.directionalLights.add(light);
    else {
      light.decay = decay || [0, 0, 1];
      if(!angle) {
        this.pointLights.add(light);
      } else {
        this.spotLights.add(light);
      }
    }
  }

  removeLight(light) {
    if(this.directionalLights.has(light)) this.directionalLights.delete(light);
    else if(this.pointLights.has(light)) this.pointLights.delete(light);
    else if(this.spotLights.has(light)) this.spotLights.delete(light);
  }

  get uniforms() {
    const MAX_LIGHT_COUNT = 16; // 最多每种光源设置16个
    this._lightData = this._lightData || {};
    const lightData = this._lightData;

    lightData.directionalLightDirection = lightData.directionalLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.directionalLightColor = lightData.directionalLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};

    lightData.pointLightPosition = lightData.pointLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.pointLightColor = lightData.pointLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.pointLightDecay = lightData.pointLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};

    lightData.spotLightDirection = lightData.spotLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightPosition = lightData.spotLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightColor = lightData.spotLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightDecay = lightData.spotLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightAngle = lightData.spotLightAngle || {value: new Float32Array(MAX_LIGHT_COUNT)};

    [...this.directionalLights].forEach((light, idx) => {
      lightData.directionalLightDirection.value.set(light.direction, idx * 3);
      lightData.directionalLightColor.value.set(light.color, idx * 3);
    });

    [...this.pointLights].forEach((light, idx) => {
      lightData.pointLightPosition.value.set(light.position, idx * 3);
      lightData.pointLightColor.value.set(light.color, idx * 3);
      lightData.pointLightDecay.value.set(light.decay, idx * 3);
    });

    [...this.spotLights].forEach((light, idx) => {
      lightData.spotLightPosition.value.set(light.position, idx * 3);
      lightData.spotLightColor.value.set(light.color, idx * 3);
      lightData.spotLightDecay.value.set(light.decay, idx * 3);
      lightData.spotLightDirection.value.set(light.direction, idx * 3);
      lightData.spotLightAngle.value[idx] = light.angle;
    });

    return {
      ambientLight: {value: this.ambientLight},
      ...lightData,
    };
  }
}

有了这个类之后，我们就可以创建并添加各种光源了。我在下面的代码中，添加了一个平行光和两个点光源，你可以看看。

const phong = new Phong();
// 添加一个平行光
phong.addLight({
  direction: [-1, 0, 0],
});
// 添加两个点光源
phong.addLight({
  position: [-3, 3, 0],
  color: [1, 0, 0],
});

phong.addLight({
  position: [3, 3, 0],
  color: [0, 0, 1],
});

2. 定义几何体材质

定义完光源之后，我们还需要定义几何体的材质（material），因为几何体材质决定了光反射的性质。

在前面的课程里，我们已经了解了一种与几何体材质有关的变量，即物体的反射率（MaterialReflection）。那在前面计算镜面反射的公式，float specular = 2.0 * pow(eyeCos, 50.0); 中也有两个常量 2.0 和 50.0，把它们也提取出来，我们就能得到两个新的变量。其中，2.0 对应 specularFactor，表示镜面反射强度，50.0 指的是 shininess，表示镜面反射的光洁度。

这样，我们就有了 3 个与材质有关的变量，分别是 matrialReflection （材质反射率）、specularFactor （镜面反射强度）、以及 shininess （镜面反射光洁度）。

然后，我们可以创建一个 Matrial 类，来定义物体的材质。与光源类相比，这个类非常简单，只是设置这三个参数，并通过 uniforms 访问器属性，获得它的 uniform 数据结构形式。

class Material {
  constructor(reflection, specularFactor = 0, shininess = 50) {
    this.reflection = reflection;
    this.specularFactor = specularFactor;
    this.shininess = shininess;
  }

  get uniforms() {
    return {
      materialReflection: {value: this.reflection},
      specularFactor: {value: this.specularFactor},
      shininess: {value: this.shininess},
    };
  }
}

那么，我们就可以创建 matrial 对象了。这里，我一共创建 4 个 matrial 对象，分别对应要显示的四个几何体的材质。

const matrial1 = new Material(new Color('#0000ff'), 2.0);
const matrial2 = new Material(new Color('#ff00ff'), 2.0);
const matrial3 = new Material(new Color('#008000'), 2.0);
const matrial4 = new Material(new Color('#ff0000'), 2.0);

有了 phong 对象和 matrial 对象，我们就可以给几何体创建 WebGL 程序了。那我们就使用上面四个 WebGL 程序，来创建真正的几何体网格，并将它们渲染出来吧。具体代码如下：

const program1 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial1.uniforms,
    ...phong.uniforms,
  },
});
const program2 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial2.uniforms,
    ...phong.uniforms,
  },
});
const program3 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial3.uniforms,
    ...phong.uniforms,
  },
});
const program4 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial4.uniforms,
    ...phong.uniforms,
  },
});

3. 实现着色器

接下来，我们重点看一下，支持 phong 反射模型的片元着色器代码是怎么实现的。这个着色器代码比较复杂，我们一段一段来看。

首先，我们来看光照相关的 uniform 变量的声明。这里，我们声明了 vec3 和 float 数组，数组的大小为 16。这样，对于每一种光源，我们都可以支持 16 个。

#define MAX_LIGHT_COUNT 16
uniform mat4 viewMatrix;

uniform vec3 ambientLight;
uniform vec3 directionalLightDirection[MAX_LIGHT_COUNT];
uniform vec3 directionalLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightPosition[MAX_LIGHT_COUNT];
uniform vec3 pointLightDecay[MAX_LIGHT_COUNT];
uniform vec3 spotLightColor[MAX_LIGHT_COUNT];
uniform vec3 spotLightDirection[MAX_LIGHT_COUNT];
uniform vec3 spotLightPosition[MAX_LIGHT_COUNT];
uniform vec3 spotLightDecay[MAX_LIGHT_COUNT];
uniform float spotLightAngle[MAX_LIGHT_COUNT];

uniform vec3 materialReflection;
uniform float shininess;
uniform float specularFactor;

接下来，我们实现计算 phong 反射模型的主题逻辑。事实上，处理平行光、点光源、聚光灯的主体逻辑类似，都是循环处理每个光源，再计算入射光方向，然后计算漫反射以及镜面反射，最终将结果返回。

float getSpecular(vec3 dir, vec3 normal, vec3 eye) {
  vec3 reflectionLight = reflect(-dir, normal);
  float eyeCos = max(dot(eye, reflectionLight), 0.0);
  return specularFactor *  pow(eyeCos, shininess);
}

vec4 phongReflection(vec3 pos, vec3 normal, vec3 eye) {
  float specular = 0.0;
  vec3 diffuse = vec3(0);

  // 处理平行光
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 dir = directionalLightDirection[i];
    if(dir.x == 0.0 && dir.y == 0.0 && dir.z == 0.0) continue;
    vec4 d = viewMatrix * vec4(dir, 0.0);
    dir = normalize(-d.xyz);
    float cos = max(dot(dir, normal), 0.0);
    diffuse += cos * directionalLightColor[i];
    specular += getSpecular(dir, normal, eye);
  }

  // 处理点光源
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 decay = pointLightDecay[i];
    if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
    vec3 dir = (viewMatrix * vec4(pointLightPosition[i], 1.0)).xyz - pos;
    float dis = length(dir);
    dir = normalize(dir);
    float cos = max(dot(dir, normal), 0.0);
    float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
    diffuse += d * cos * pointLightColor[i];
    specular += getSpecular(dir, normal, eye);
  }

  // 处理聚光灯
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 decay = spotLightDecay[i];
    if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;

    vec3 dir = (viewMatrix * vec4(spotLightPosition[i], 1.0)).xyz - pos;
    float dis = length(dir);
    dir = normalize(dir);

    // 聚光灯的朝向
    vec3 spotDir = (viewMatrix * vec4(spotLightDirection[i], 0.0)).xyz;
    // 通过余弦值判断夹角范围
    float ang = cos(spotLightAngle[i]);
    float r = step(ang, dot(dir, normalize(-spotDir)));

    float cos = max(dot(dir, normal), 0.0);
    float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
    diffuse += r * d * cos * spotLightColor[i];
    specular += r * getSpecular(dir, normal, eye);
  }

  return vec4(diffuse, specular);
}

最后，我们在 main 函数中，调用 phongReflection 函数来合成颜色。代码如下：

void main() {
  vec3 eyeDirection = normalize(vCameraPos - vPos);
  vec4 phong = phongReflection(vPos, vNormal, eyeDirection);

  // 合成颜色
  gl_FragColor.rgb = phong.w + (phong.xyz + ambientLight) * materialReflection;
  gl_FragColor.a = 1.0;
}

最终呈现的视觉效果如下图所示：

你注意一下上图右侧的球体。因为我们一共设置了 3 个光源，一个平行光、两个点光源，它们都能够产生镜面反射。所以，这些光源叠加在一起后，这个球体就呈现出 3 个镜面高光。

Phong 反射模型的局限性

虽然，phong 反射模型已经比较接近于真实的物理模型，不过它仍然是真实模型的一种近似。因为它没有考虑物体反射光对其他物体的影响，也没有考虑物体对光线遮挡产生的阴影。

当然，我们可以完善这个模型。比如，将物体本身反射光（主要是镜面反射光）对其他物体的影响纳入到模型中。另外，我们也要考虑物体的阴影。当我们把这些因素更多地考虑进去的时候，我们的模型就会更加接近真实世界的物理模型。

当我们渲染 3D 图形的时候，要呈现越接近真实的效果，往往要考虑更多的参数，因此所需的计算量也越大，那我们就需要有更强的渲染能力，比如，更好的显卡，更快的 CPU 和 GPU，并且也需要我们尽可能地优化计算的性能。

但是，有很多时候，我们需要在细节和性能上做出平衡和取舍。那性能优化的部分，也是我们课程的重点，我会在性能篇详细来讲。这节课，我们就重点关注反射模型，总结出完整的 Phong 反射模型就可以了。

要点总结

今天，我们把环境光、平行光、点光源、聚光灯这四种光源整合，并且在上节课讲的漫反射的基础上，添加了镜面反射，形成了完整的 Phong 反射模型。在这里，我们实现的着色器代码能够结合四种光源的效果，除了环境光外，每种光源还可以设置多个。

在 Phong 反射模型中，光照在物体上的最终效果，由各个光源的性质（参数）和物体的表面材质共同决定。

Phong 反射模型也只是真实世界的一种近似，因为我们并没有考虑物体之间反射光的相互影响，也没有考虑光线的遮挡。如果把这些因素考虑进去，那我们的模型可以更接近真实世界了。

小试牛刀

我们知道，平行光、点光源和聚光灯是三种常见的方向光，但真实世界还有其他的方向光，比如探照灯，它是一种有范围的平行光，类似于聚光灯，但又不完全一样。你能给物体实现探照灯效果吗？

这里，我先把需要用到的参数告诉你，包括光源方向 searchLightDirection、光源半径 searchLightRadius、光源位置 searchLightPosition、光照颜色 searchLightColor。你可以用 OGL 实现探照灯效果，然后把对应的着色器代码写在留言区。

探照灯示意图

而且，探照灯的光照截面不一定是圆形，也可以是其他图形，比如三角形、菱形、正方形，你也可以试着让它支持不同的光照截面。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课再见！

源码

课程中完整代码详见GitHub 仓库

推荐阅读

Phong 反射模型简介

25 | 如何用法线贴图模拟真实物体表面

你好，我是月影。

上节课，我们讲了光照的 Phong 反射模型，并使用它给几何体添加了光照效果。不过，我们使用的几何体表面都是平整的，没有凹凸感。而真实世界中，大部分物体的表面都是凹凸不平的，这肯定会影响光照的反射效果。

因此，只有处理好物体凹凸表面的光照效果，我们才能更加真实地模拟物体表面。在图形学中就有一种对应的技术，叫做法线贴图。今天，我们就一起来学习一下。

如何使用法线贴图给几何体表面增加凹凸效果？

那什么是法线贴图？我们直接通过一个例子来理解。

首先，我们用 Phong 反射模型绘制一个灰色的立方体，并给它添加两道平行光。具体的代码和效果如下：

import {Phong, Material, vertex as v, fragment as f} from '../common/lib/phong.js';

const scene = new Transform();

const phong = new Phong();
phong.addLight({
  direction: [0, -3, -3],
});
phong.addLight({
  direction: [0, 3, 3],
});
const matrial = new Material(new Color('#808080'));

const program = new Program(gl, {
  vertex: v,
  fragment: f,
  uniforms: {
    ...phong.uniforms,
    ...matrial.uniforms,
  },
});

const geometry = new Box(gl);
const cube = new Mesh(gl, {geometry, program});
cube.setParent(scene);
cube.rotation.x = -Math.PI / 2;

现在这个立方体的表面是光滑的，如果我们想在立方体的表面贴上凹凸的花纹。我们可以加载一张法线纹理，这是一张偏蓝色调的纹理图片。

const normalMap = await loadTexture('../assets/normal_map.png');

为什么这张纹理图片是偏蓝色调的呢？实际上，这张纹理图片保存的是几何体表面的每个像素的法向量数据。我们知道，正常情况下，光滑立方体每个面的法向量是固定的，如下图所示：

但如果表面有凹凸的花纹，那不同位置的法向量就会发生变化。在切线空间中，因为法线都偏向于 z 轴，也就是法向量偏向于 (0,0,1)，所以转换成的法线纹理就偏向于蓝色。如果我们根据花纹将每个点的法向量都保存下来，就会得到上面那张法线纹理的图片。

如何理解切线空间？

我刚才提到了一个词，切线空间，那什么是切线空间呢？切线空间（Tangent Space）是一个特殊的坐标系，它是由几何体顶点所在平面的 uv 坐标和法线构成的。

切线空间

切线空间的三个轴，一般用 T (Tangent)、B (Bitangent)、N (Normal) 三个字母表示，所以切线空间也被称为 TBN 空间。其中 T 表示切线、B 表示副切线、N 表示法线。

对于大部分三维几何体来说，因为每个点的法线不同，所以它们各自的切线空间也不同。

接下来，我们来具体说说，切线空间中的 TBN 是怎么计算的。

首先，我们来回忆一下，怎么计算几何体三角形网格的法向量。假设一个三角形网格有三个点 v1、v2、v3，我们把边 v1v2 记为 e1，边 v1v3 记为 e2，那三角形的法向量就是 e1 和 e2 的叉积表示的归一化向量。用 JavaScript 代码实现就是下面这样：

function getNormal(v1, v2, v3) {
  const e1 = Vec3.sub(v2, v1);
  const e2 = Vec3.sub(v3, v1);
  const normal = Vec3.cross(e1, e1).normalize();
  return normal;
}

而计算切线和副切线，要比计算法线复杂得多，不过，因为数学推导过程比较复杂，我们只要记住结论就可以了。

如上图和公式，我们就可以通过 UV 坐标和点 P1、P2、P3 的坐标求出对应的 T 和 B 坐标了，对应的 JavaScript 函数如下：

function createTB(geometry) {
  const {position, index, uv} = geometry.attributes;
  if(!uv) throw new Error('NO uv.');
  function getTBNTriangle(p1, p2, p3, uv1, uv2, uv3) {
    const edge1 = new Vec3().sub(p2, p1);
    const edge2 = new Vec3().sub(p3, p1);
    const deltaUV1 = new Vec2().sub(uv2, uv1);
    const deltaUV2 = new Vec2().sub(uv3, uv1);

    const tang = new Vec3();
    const bitang = new Vec3();

    const f = 1.0 / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);

    tang.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
    tang.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
    tang.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);

    tang.normalize();

    bitang.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
    bitang.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
    bitang.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);

    bitang.normalize();

    return {tang, bitang};
  }

  const size = position.size;
  if(size < 3) throw new Error('Error dimension.');

  const len = position.data.length / size;
  const tang = new Float32Array(len * 3);
  const bitang = new Float32Array(len * 3);

  for(let i = 0; i < index.data.length; i += 3) {
    const i1 = index.data[i];
    const i2 = index.data[i + 1];
    const i3 = index.data[i + 2];

    const p1 = [position.data[i1 * size], position.data[i1 * size + 1], position.data[i1 * size + 2]];
    const p2 = [position.data[i2 * size], position.data[i2 * size + 1], position.data[i2 * size + 2]];
    const p3 = [position.data[i3 * size], position.data[i3 * size + 1], position.data[i3 * size + 2]];

    const u1 = [uv.data[i1 * 2], uv.data[i1 * 2 + 1]];
    const u2 = [uv.data[i2 * 2], uv.data[i2 * 2 + 1]];
    const u3 = [uv.data[i3 * 2], uv.data[i3 * 2 + 1]];

    const {tang: t, bitang: b} = getTBNTriangle(p1, p2, p3, u1, u2, u3);
    tang.set(t, i1 * 3);
    tang.set(t, i2 * 3);
    tang.set(t, i3 * 3);
    bitang.set(b, i1 * 3);
    bitang.set(b, i2 * 3);
    bitang.set(b, i3 * 3);
  }
  geometry.addAttribute('tang', {data: tang, size: 3});
  geometry.addAttribute('bitang', {data: bitang, size: 3});
  return geometry;
}

虽然上面这段代码比较长，但并不复杂。具体的思路就是按照我给出的公式，先进行向量计算，然后将 tang 和 bitang 的值添加到 geometry 对象中去。

构建 TBN 矩阵来计算法向量

有了 tang 和 bitang 之后，我们就可以构建 TBN 矩阵来计算法线了。这里的 TBN 矩阵的作用，就是将法线贴图里面读取的法向量数据，转换为对应的切线空间中实际的法向量。这里的切线空间，实际上对应着我们观察者（相机）位置的坐标系。

接下来，我们对应顶点着色器和片元着色器来说说，怎么构建 TBN 矩阵得出法线方向。

先看顶点着色器，我们增加了 tang 和 bitang 这两个属性。注意，这里我们用了 webgl2.0 的写法，因为 WebGL2.0 对应 OpenGL ES3.0，所以这段代码和我们之前看到的着色器代码略有不同。

首先它的第一行声明 #version 300 es 表示这段代码是 OpenGL ES3.0 的，然后我们用 in 和 out 对应变量的输入和输出，来取代 WebGL2.0 的 attribute 和 varying，其他的地方基本和 WebGL1.0 一样。因为 OGL 默认支持 WebGL2.0，所以在后续例子中你还会看到更多 OpenGL ES3.0 的着色器写法，不过因为两个版本差别不大，也不会妨碍我们理解代码。

#version 300 es
precision highp float;

in vec3 position;
in vec3 normal;
in vec2 uv;
in vec3 tang;
in vec3 bitang;

uniform mat4 modelMatrix;
uniform mat4 modelViewMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
uniform vec3 cameraPosition;

out vec3 vNormal;
out vec3 vPos;
out vec2 vUv;
out vec3 vCameraPos;
out mat3 vTBN;

void main() {
  vec4 pos = modelViewMatrix * vec4(position, 1.0);
  vPos = pos.xyz;
  vUv = uv;
  vCameraPos = (viewMatrix * vec4(cameraPosition, 1.0)).xyz;
  vNormal = normalize(normalMatrix * normal);

  vec3 N = vNormal;
  vec3 T = normalize(normalMatrix * tang);
  vec3 B = normalize(normalMatrix * bitang);

  vTBN = mat3(T, B, N);

  gl_Position = projectionMatrix * pos;
}

接着来看代码，我们通过 normal、tang 和 bitang 建立 TBN 矩阵。注意，因为 normal、tang 和 bitang 都需要换到世界坐标中，所以我们要记得将它们左乘法向量矩阵 normalMatrix，然后我们构建 TBN 矩阵 (vTBN=mat(T,B,N))，将它传给片元着色器。

下面，我们接着来看片元着色器。

#version 300 es
precision highp float;

#define MAX_LIGHT_COUNT 16
uniform mat4 viewMatrix;

uniform vec3 ambientLight;
uniform vec3 directionalLightDirection[MAX_LIGHT_COUNT];
uniform vec3 directionalLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightPosition[MAX_LIGHT_COUNT];
uniform vec3 pointLightDecay[MAX_LIGHT_COUNT];
uniform vec3 spotLightColor[MAX_LIGHT_COUNT];
uniform vec3 spotLightDirection[MAX_LIGHT_COUNT];
uniform vec3 spotLightPosition[MAX_LIGHT_COUNT];
uniform vec3 spotLightDecay[MAX_LIGHT_COUNT];
uniform float spotLightAngle[MAX_LIGHT_COUNT];

uniform vec3 materialReflection;
uniform float shininess;
uniform float specularFactor;

uniform sampler2D tNormal;

in vec3 vNormal;
in vec3 vPos;
in vec2 vUv;
in vec3 vCameraPos;
in mat3 vTBN;

out vec4 FragColor;

float getSpecular(vec3 dir, vec3 normal, vec3 eye) {
  vec3 reflectionLight = reflect(-dir, normal);
  float eyeCos = max(dot(eye, reflectionLight), 0.0);
  return specularFactor *  pow(eyeCos, shininess);
}

vec4 phongReflection(vec3 pos, vec3 normal, vec3 eye) {
  float specular = 0.0;
  vec3 diffuse = vec3(0);

  // 处理平行光
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 dir = directionalLightDirection[i];
    if(dir.x == 0.0 && dir.y == 0.0 && dir.z == 0.0) continue;
    vec4 d = viewMatrix * vec4(dir, 0.0);
    dir = normalize(-d.xyz);
    float cos = max(dot(dir, normal), 0.0);
    diffuse += cos * directionalLightColor[i];
    specular += getSpecular(dir, normal, eye);
  }

  // 处理点光源
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 decay = pointLightDecay[i];
    if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
    vec3 dir = (viewMatrix * vec4(pointLightPosition[i], 1.0)).xyz - pos;
    float dis = length(dir);
    dir = normalize(dir);
    float cos = max(dot(dir, normal), 0.0);
    float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
    diffuse += d * cos * pointLightColor[i];
    specular += getSpecular(dir, normal, eye);
  }

  // 处理聚光灯
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 decay = spotLightDecay[i];
    if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;

    vec3 dir = (viewMatrix * vec4(spotLightPosition[i], 1.0)).xyz - pos;
    float dis = length(dir);
    dir = normalize(dir);

    // 聚光灯的朝向
    vec3 spotDir = (viewMatrix * vec4(spotLightDirection[i], 0.0)).xyz;
    // 通过余弦值判断夹角范围
    float ang = cos(spotLightAngle[i]);
    float r = step(ang, dot(dir, normalize(-spotDir)));

    float cos = max(dot(dir, normal), 0.0);
    float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
    diffuse += r * d * cos * spotLightColor[i];
    specular += r * getSpecular(dir, normal, eye);
  }

  return vec4(diffuse, specular);
}

vec3 getNormal() {
  vec3 n = texture(tNormal, vUv).rgb * 2.0 - 1.0;
  return normalize(vTBN * n);
}

void main() {
  vec3 eyeDirection = normalize(vCameraPos - vPos);
  vec3 normal = getNormal();
  vec4 phong = phongReflection(vPos, normal, eyeDirection);

  // 合成颜色
  FragColor.rgb = phong.w + (phong.xyz + ambientLight) * materialReflection;
  FragColor.a = 1.0;
}

片元着色器代码虽然也很长，但也并不复杂。因为其中的 Phong 反射模型，我们已经比较熟悉了。剩下的部分，我们重点理解，怎么从法线纹理中提取数据和 TBN 矩阵，来计算对应的法线就行了。具体的计算方法就是把法线纹理贴图中提取的数据转换到[-1，1]区间，然后左乘 TBN 矩阵并归一化。

然后，我们将经过处理之后的法向量传给 phongReflection 计算光照，就得到了法线贴图后的结果，效果如下图：

到这里我们就实现了完整的法线贴图。法线贴图就是根据法线纹理中保存的法向量数据以及 TBN 矩阵，将实际的法线计算出来，然后用实际的法线来计算光照的反射。具体点来说，要实现法线贴图，我们需要通过顶点数据计算几何体的切线和副切线，然后得到 TBN 矩阵，用 TBN 矩阵和法线纹理数据来计算法向量，从而完成法线贴图。

使用偏导数来实现法线贴图

但是，构建 TBN 矩阵求法向量的方法还是有点麻烦。事实上，还有一种更巧妙的方法，不需要用顶点数据计算几何体的切线和副切线，而是直接用坐标插值和法线纹理来计算。

vec3 getNormal() {
  vec3 pos_dx = dFdx(vPos.xyz);
  vec3 pos_dy = dFdy(vPos.xyz);
  vec2 tex_dx = dFdx(vUv);
  vec2 tex_dy = dFdy(vUv);

  vec3 t = normalize(pos_dx * tex_dy.t - pos_dy * tex_dx.t);
  vec3 b = normalize(-pos_dx * tex_dy.s + pos_dy * tex_dx.s);
  mat3 tbn = mat3(t, b, normalize(vNormal));

  vec3 n = texture(tNormal, vUv).rgb * 2.0 - 1.0;
  return normalize(tbn * n);
}

如上面代码所示，dFdx、dFdy 是 GLSL 内置函数，可以求插值的属性在 x、y 轴上的偏导数。那我们为什么要求偏导数呢？偏导数其实就代表插值的属性向量在 x、y 轴上的变化率，或者说曲面的切线。然后，我们再将顶点坐标曲面切线与 uv 坐标的切线求叉积，就能得到垂直于两条切线的法线。

那我们在 x、y 两个方向上求出的两条法线，就对应 TBN 空间的切线 tang 和副切线 bitang。然后，我们使用偏导数构建 TBN 矩阵，同样也是把 TBN 矩阵左乘从法线纹理中提取出的值，就可以计算出对应的法向量了。

这样做的好处是，我们不需要预先计算几何体的 tang 和 bitang 了。不过在片元着色器中计算偏导数也有一定的性能开销，所以各有利弊，我们可以根据不同情况选择不同的方案。

法线贴图的应用

法线贴图的两种实现方式，我们都学会了。那法线贴图除了给几何体表面增加花纹以外，还可以用来增强物体细节，让物体看起来更加真实。比如说，在实现一个石块被变化的光源照亮效果的时候，我们就可以运用法线贴图技术，让石块的表面纹路细节显得非常的逼真。我把对应的片元着色器核心代码放在了下面，你可以利用今天学到的知识自己来实现一下。

uniform float uTime;

void main() {
  vec3 eyeDirection = normalize(vCameraPos - vPos);
  vec3 normal = getNormal();
  vec4 phong = phongReflection(vPos, normal, eyeDirection);
  // vec4 phong = phongReflection(vPos, vNormal, eyeDirection);

  vec3 tex = texture(tMap, vUv).rgb;
  vec3 light = normalize(vec3(sin(uTime), 1.0, cos(uTime)));
  float shading = dot(normal, light) * 0.5;

  FragColor.rgb = tex + shading;
  FragColor.a = 1.0;
}

要点总结

这节课，我们详细说了法线贴图这个技术。法线贴图是一种经典的图形学技术，可以用来给物体表面增加细节，让我们实现的效果更逼真。

具体来说，法线贴图是用一张图片来存储表面的法线数据。这张图片叫做法线纹理，它上面的每个像素对应一个坐标点的法线数据。

要想使用法线纹理的数据，我们还需要构建 TBN 矩阵。这个矩阵通过向量、矩阵乘法将法线数据转换到世界坐标中。

构建 TBN 矩阵我们有两个方法，一个是根据几何体顶点数据来计算切线（Tangent）、副切线（Bitangent），然后结合法向量一起构建 TBN 矩阵。另一个方法是使用偏导数来计算，这样我们就不用预先在顶点中计算 Tangent 和 Bitangent 了。两种方法各有利弊，我们可以根据实际情况来合理选择。

小试牛刀

这里，我给出了两张图片，一张是纹理图片，一张是法线纹理，你能用它们分别来绘制一面墙，并且引入 Phong 反射模型，来实现光照效果吗？你还可以思考一下，应用法线贴图和不应用法线贴图绘制出来的墙，有什么差别？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课再见！

源码

课程中完整示例代码见GitHub 仓库

推荐阅读

Normal mapping

26 | 如何绘制带宽度的曲线？

你好，我是月影。

在可视化应用中，我们经常需要绘制一些带有特定宽度的曲线。比如说，在地理信息可视化中，我们会使用曲线来描绘路径，而在 3D 地球可视化中，我们会使用曲线来描述飞线、轮廓线等等。

在 Canvas2D 中，要绘制带宽度的曲线非常简单，我们直接设置上下文对象的 lineWidth 属性就行了。但在 WebGL 中，绘制带宽度的曲线是一个难点，很多开发者都在这一步被难住过。

那今天，我就来说说怎么用 Canvas2D 和 WebGL 分绘制曲线。我要特别强调一下，我们讲的曲线指广义的曲线，线段、折线和平滑曲线都包含在内。

如何用 Canvas2D 绘制带宽度的曲线？

刚才我说了，用 Canvas2D 绘制曲线非常简单。这是为什么呢？因为 Canvas2D 提供了相应的 API，能够绘制出不同宽度、具有特定连线方式和线帽形状的曲线。

这句话怎么理解呢？我们从两个关键词，“连线方式（lineJoin）”和“线帽形状（lineCap）”入手理解。

我们知道，曲线是由线段连接而成的，两个线段中间转折的部分，就是 lineJoin。如果线宽只有一个像素，那么连接处没有什么不同的形式，就是直接连接。但如果线宽超过一个像素，那么连接处的缺口，就会有不同的填充方式，而这些不同的填充方式，就对应了不同的 lineJoin。

比如说，你可以看我给出的这张图，上面就显示了四种不同的 lineJoin。其中，miter 是尖角，round 是圆角，bevel 是斜角，none 是不添加 lineJoin。很好理解，我就不多说了

4种不同的lineJoin

说完了 lineJoin，那什么是 lineCap 呢？lineCap 就是指曲线头尾部的形状，它有三种类型。第一种是 square，方形线帽，它会在线段的头尾端延长线宽的一半。第二种 round 也叫圆弧线帽，它会在头尾端延长一个半圆。第三种是 butt，就是不添加线帽。

3种不同的lineCap

理解了这两个关键词之后，我们接着尝试一下，怎么在 Canvas 的上下文中，通过设置 lineJoin 和 lineCap 属性，来实现不同的曲线效果。

首先，我们要实现一个 drawPolyline 函数。这个函数非常简单，就是设置 lineWidth、lingJoin、lineCap，然后根据 points 数据的内容设置绘图指令执行绘制。

function drawPolyline(context, points, {lineWidth = 1, lineJoin = 'miter', lineCap = 'butt'} = {}) {
  context.lineWidth = lineWidth;
  context.lineJoin = lineJoin;
  context.lineCap = lineCap;
  context.beginPath();
  context.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]);
  }
  context.stroke();
}

在设置 lingJoin、lineCap 时候，我们要注意，Canvas2D 的 lineJoin 只支持 miter、bevel 和 round，不支持 none。lineCap 支持 butt、square 和 round。

接着，我们就可以执行 JavaScript 代码绘制曲线了。比如，我们绘制两条线，一条宽度为 10 个像素的红线，另一条宽度为 1 个像素的蓝线，具体的代码：

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const points = [
  [100, 100],
  [100, 200],
  [200, 150],
  [300, 200],
  [300, 100],
];
ctx.strokeStyle = 'red';
drawPolyline(ctx, points, {lineWidth: 10});
ctx.strokeStyle = 'blue';
drawPolyline(ctx, points);

因为我们把连接设置成 miter、线帽设置成了 butt，所以我们绘制出来的曲线，是尖角并且不带线帽的。

其实，我们还可以修改 lineJoins 和 lineCap 参数。比如，我们将线帽设为圆的，连接设为斜角。除此之外，你还可以尝试不同的组合，我就不再举例了。

ctx.strokeStyle = 'red';
drawPolyline(ctx, points, {lineWidth: 10, lineCap: 'round', lineJoin: 'bevel'});

除了 lineJoin 和 lineCap 外，我们还可以设置 Canvas2D 上下文的 miterLimit 属性，来改变 lineJoin 等于 miter 时的连线形式，miterLimit 属性等于 miter 和线宽的最大比值。当我们把 lineJoin 设置成 miter 的时候，miterLimit 属性就会限制尖角的最大值。

那具体会产生什么效果呢？我们可以先修改 drawPolyline 代码添加 miterLimit。代码如下：

function drawPolyline(context, points, {lineWidth = 1, lineJoin = 'miter', lineCap = 'butt', miterLimit = 10} = {}) {
  context.lineWidth = lineWidth;
  context.lineJoin = lineJoin;
  context.lineCap = lineCap;
  context.miterLimit = miterLimit;
  context.beginPath();
  context.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]);
  }
  context.stroke();
}

然后，我们修改参数，把 miterLimit: 设置为 1.5：

ctx.strokeStyle = 'red';
drawPolyline(ctx, points, {lineWidth: 10, lineCap: 'round', lineJoin: 'miter', miterLimit: 1.5});

你会发现，这样渲染出来的图形，它两侧的转角由于超过了 miterLimit 限制，所以表现为斜角，而中间的转角因为没有超过 miterLimit 限制，所以是尖角。

总的来说，Canvas2D 绘制曲线的方法很简单，只要我们调用对应的 API 就可以了。但用 WebGL 来绘制同样的曲线会非常麻烦。在详细讲解之前，我希望你先记住 lineJoin、lineCap 以及 miterLimit 这些属性，在 WebGL 中我们需要自己去实现它们。接下来，我们一起来看一下 WebGL 中是怎么做的。

如何用 WebGL 绘制带宽度的曲线

我们先从绘制宽度为 1 的曲线开始。因为 WebGL 本身就支持线段类的图元，所以我们直接用图元就能绘制出宽度为 1 的曲线。

下面，我结合代码来说说具体的绘制过程。与 Canvas2D 类似，我们直接设置 position 顶点坐标，然后设置 mode 为 gl.LINE_STRIP。这里的 LINE_STRIP 是一种图元类型，表示以首尾连接的线段方式绘制。这样，我们就可以得到宽度为 1 的折线了。具体的代码和效果如下所示：

import {Renderer, Program, Geometry, Transform, Mesh} from '../common/lib/ogl/index.mjs';

const vertex = `
  attribute vec2 position;

  void main() {
    gl_PointSize = 10.0;
    float scale = 1.0 / 256.0;
    mat3 projectionMatrix = mat3(
      scale, 0, 0,
      0, -scale, 0,
      -1, 1, 1
    );
    vec3 pos = projectionMatrix * vec3(position, 1);
    gl_Position = vec4(pos.xy, 0, 1);
  }
`;

const fragment = `
  precision highp float;
  void main() {
    gl_FragColor = vec4(1, 0, 0, 1);
  }
`;

const canvas = document.querySelector('canvas');
const renderer = new Renderer({
  canvas,
  width: 512,
  height: 512,
});

const gl = renderer.gl;
gl.clearColor(1, 1, 1, 1);

const program = new Program(gl, {
  vertex,
  fragment,
});

const geometry = new Geometry(gl, {
position: {size: 2,
  data: new Float32Array(
    [
      100, 100,
      100, 200,
      200, 150,
      300, 200,
      300, 100,
    ],
  )},
});

const scene = new Transform();
const polyline = new Mesh(gl, {geometry, program, mode: gl.LINE_STRIP});
polyline.setParent(scene);

renderer.render({scene});

你可能会问，我们不能直接修改 gl_PointSize，来给折线设置宽度吗？很遗憾，这是不行的。因为 gl_PointSize 只能影响 gl.POINTS 图元的显示，并不能对线段图元产生影响。

那我们该怎么让线的宽度大于 1 个像素呢？

通过挤压 (extrude) 曲线绘制有宽度的曲线

我们可以用一种挤压（Extrude）曲线的技术，通过将曲线的顶点沿法线方向向两侧移出，让 1 个像素的曲线变宽。

那挤压曲线要怎么做呢？我们先看一张示意图：

挤压线段

如上图所示，黑色折线是原始的 1 个像素宽度的折线，蓝色虚线组成的是我们最终要生成的带宽度曲线，红色虚线是顶点移动的方向。因为折线两个端点的挤压只和一条线段的方向有关，而转角处顶点的挤压和相邻两条线段的方向都有关，所以顶点移动的方向，我们要分两种情况讨论。

首先，是折线的端点。假设线段的向量为（x, y），因为它移动方向和线段方向垂直，所以我们只要沿法线方向移动它就可以了。根据垂直向量的点积为 0，我们很容易得出顶点的两个移动方向为（-y, x）和（y, -x）。如下图所示：

折线端点挤压方向

端点挤压方向确定了，接下来要确定转角的挤压方向了，我们还是看示意图。

转角的挤压方向示意图

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

现在我们得到了挤压方向，接下来就需要确定挤压向量的长度。

首先是折线端点的挤压长度，它等于 lineWidth 的一半。而转角的挤压长度就比较复杂了，我们需要再计算一下。

计算转角挤压长度示意图

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

具体用 JavaScript 实现的代码如下所示：

function extrudePolyline(gl, points, {thickness = 10} = {}) {
  const halfThick = 0.5 * thickness;
  const innerSide = [];
  const outerSide = [];

  // 构建挤压顶点
  for(let i = 1; i < points.length - 1; i++) {
    const v1 = (new Vec2()).sub(points[i], points[i - 1]).normalize();
    const v2 = (new Vec2()).sub(points[i], points[i + 1]).normalize();
    const v = (new Vec2()).add(v1, v2).normalize(); // 得到挤压方向
    const norm = new Vec2(-v1.y, v1.x); // 法线方向
    const cos = norm.dot(v);
    const len = halfThick / cos;
    if(i === 1) { // 起始点
      const v0 = new Vec2(...norm).scale(halfThick);
      outerSide.push((new Vec2()).add(points[0], v0));
      innerSide.push((new Vec2()).sub(points[0], v0));
    }
    v.scale(len);
    outerSide.push((new Vec2()).add(points[i], v));
    innerSide.push((new Vec2()).sub(points[i], v));
    if(i === points.length - 2) { // 结束点
      const norm2 = new Vec2(v2.y, -v2.x);
      const v0 = new Vec2(...norm2).scale(halfThick);
      outerSide.push((new Vec2()).add(points[points.length - 1], v0));
      innerSide.push((new Vec2()).sub(points[points.length - 1], v0));
    }
  }
  ...
}

在这段代码中，v1、v2 是线段的延长线，v 是挤压方向，我们计算法线方向与挤压方向的余弦值，就能算出挤压长度了。你还要注意，我们要把起始点和结束点这两个端点的挤压也给添加进去，也就是两个 if 条件中的处理逻辑。

这样一来，我们就把挤压之后的折线顶点坐标给计算出来了。向内和向外挤压的点现在分别保存在 innerSide 和 outerSide 数组中。

接下来，我们就要构建对应的 Geometry 对象，所以我们继续添加 extrudePolyline 函数的后半部分。

function extrudePolyline(gl, points, {thickness = 10} = {})
  ...
  const count = innerSide.length * 4 - 4;
  const position = new Float32Array(count * 2);
  const index = new Uint16Array(6 * count / 4);

  // 创建 geometry 对象
  for(let i = 0; i < innerSide.length - 1; i++) {
    const a = innerSide[i],
      b = outerSide[i],
      c = innerSide[i + 1],
      d = outerSide[i + 1];

    const offset = i * 4;
    index.set([offset, offset + 1, offset + 2, offset + 2, offset + 1, offset + 3], i * 6);
    position.set([...a, ...b, ...c, ...d], i * 8);
  }

  return new Geometry(gl, {
    position: {size: 2, data: position},
    index: {data: index},
  });
}

这一步骤就非常简单了，我们根据 innerSide 和 outerSide 中的顶点来构建三角网格化的几何体顶点数据，最终返回 Geometry 对象。

构建折线的顶点数据

最后，我们只要调用 extrudePolyline，传入折线顶点和宽度，然后用返回的 Geometry 对象来构建三角网格对象，将它渲染出来就可以了。

const geometry = extrudePolyline(gl, points, {lineWidth: 10});

const scene = new Transform();
const polyline = new Mesh(gl, {geometry, program});
polyline.setParent(scene);

renderer.render({scene});

我们最终渲染出来的效果如下图：

这样，我们就在 WebGL 中实现了，与 Canvas2D 一样带宽度的曲线。

当然，这里我们只实现了最基础的带宽度曲线，它对应于 Canvas2D 中的 lineJoin 为 miter，lineCap 为 butt 的曲线。不过，想要实现 lineJoins 为 bevel 或 round，lineCap 为 square 或 round 的曲线，也不会太困难。我们可以基于 extrudePolyline 函数，对它进行扩展，计算出相应属性下对应的顶点就行了。因为基本原理是一样的，我就不详细说了，我把扩展的任务留给你作为课后练习。

要点总结

这节课，我们讲了绘制带宽度曲线的方法。

首先，在 Canvas2D 中，绘制这样的曲线比较简单，我们直接通过 API 设置 lineWidth 即可。而且，Canvas2D 还支持不同的 lineJoin、lineCap 设置以及 miterLimit 设置。

在 WebGL 中，绘制带宽度的曲线则比较麻烦，因为没有现成的 API 可以使用。这个时候，我们可以使用挤压曲线的技术来得到带宽度的曲线，挤压曲线的具体步骤可以总结为三步：

确定端点和转角的挤压方向，端点可以沿线段的法线挤压，转角则通过两条线段延长线的单位向量求和的方式获得。

确定端点和转角挤压的长度，端点两个方向的挤压长度是线宽 lineWidth 的一半。求转角挤压长度的时候，我们要先计算方向向量和线段法线的余弦，然后将线宽 lineWidth 的一半除以我们计算出的余弦值。

由步骤 1、2 计算出顶点后，我们构建三角网格化的几何体顶点数据，然后将 Geometry 对象返回。

这样，我们就可以用 WebGL 绘制出有宽度的曲线了。

小试牛刀

你能修改 extrudePolyline 函数，让它支持 lineCap 为 square 和 round 吗？或者让它支持 lineJoin 为 round 吗？

我想让你试着修改一下 extrudePolyline 函数，让它支持 lineJoin 为 bevel，以及 miterLimit。并且，当 lineJoin 为 miter 的时候，如果转角挤压长度超过了 miterLimit，我们就按照 bevel 处理向外的挤压。

那通过今天的学习，你是不是已经学会绘制带宽度曲线的方法。那不妨就把这节课分享给你的朋友，也帮助他解决这个难题吧。好了，今天的内容就到这里了，我们下节课再见

源码

课程完整示例代码详见GitHub 仓库

27 | 案例：如何实现简单的3D可视化图表？

你好，我是月影。

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

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

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

GitHub默认的贡献图表可视化展现示意图

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

想要实现可视化图表，第一步就是准备数据。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 现象

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

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

如果A和B深度（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

28 | Canvas、SVG与WebGL在性能上的优势与劣势

你好，我是月影。

性能优化，一直以来都是前端开发的难点。

我们知道，前端性能是一块比较复杂的内容，由许多因素决定，比如，网页内容和资源文件的大小、请求数、域名、服务器配置、CDN 等等。如果你能把性能优化好，就能极大地增强用户体验。

在可视化领域也一样，可视化因为要突出数据表达的内容，经常需要设计一些有视觉震撼力的图形效果，比如，复杂的粒子效果和大量元素的动态效果。想要实现这些效果，图形系统的渲染性能就必须非常好，能够在用户的浏览器上稳定流畅地渲染出想要的视觉效果。

那么针对可视化渲染，我们都要解决哪些性能问题呢？

可视化渲染的性能问题有哪些？

由于前端的可视化也是在 Web 上展现的，因此像网页大小这些因素也会影响它的性能。而且，无论是可视化还是普通 Web 前端，针对这些因素进行性能优化的原理和手段都一样。

所以我今天想和你聊的是，可视化方面特殊的性能问题。它们在我们熟悉的 Web 前端工作中并不常见，通常只在可视化中绘制复杂图形的时候，我们才需要重点考虑。这些问题大体上可以分为两类，一类是渲染效率问题，另一类是计算问题。

我们先来看它们的定义，渲染效率问题指的是图形系统在绘图部分所花费的时间，而计算问题则是指绘图之外的其他处理所花费的时间，包括图形数据的计算、正常的程序逻辑处理等等。

我们知道，在浏览器上渲染动画，每一秒钟最高达到 60 帧左右。也就是说，我们可以在 1 秒钟内完成 60 次图像的绘制，那么完成一次图像绘制的时间就是 1000/60（1 秒 =1000 毫秒），约等于 16 毫秒。

换句话说，如果我们能在 16 毫秒内完成图像的计算与渲染过程，那视觉呈现就可以达到完美的 60fps（即 60 帧每秒，fps 全称是 frame per second，是帧率单位）。但是，在复杂的图形渲染时，我们的帧率很可能达不到 60fps。

所以，我们只能退而求其次，最低可以选择 24fps，就相当于图形系统要在大约 42 毫秒内完成一帧图像的绘制。这是在我们的感知里，达到比较流畅的动画效果的最低帧率了。要保证这个帧率，我们就必须保证计算加上渲染的时间不能超过 42 毫秒。

因为计算问题与数据和算法有关，所以我们后面会专门讨论。这里，我们先关注渲染效率的问题，这个问题和图形系统息息相关。

我们知道，Canvas2D、SVG 和 WebGL 等图形系统各自的特点不同，所以它们在绘制不同图形时的性能影响也不同，会表现出不同的性能瓶颈。其实，通过基础篇的学习，我们也大体上知道了这些图形系统的区别和优劣。那今天，我们就在此基础上，深入讨论一下影响它们各自性能的关键因素，理解了这些要素，我们针对不同图形系统，就能快速找到需要进行性能优化的点了。

影响 Canvas 渲染性能的 2 大要素

我们知道，Canvas 是指令式绘图系统，它通过绘图指令来完成图形的绘制。那么我们很容易就会想到 2 个影响因素，首先绘制图形的数量越多，我们需要的绘图指令就越多，花费的渲染时间也会越多。其次，画布上绘制的图形越大，绘图指令执行的时间也会增多，那么花费的渲染时间也会越多。

这些其实都是我们现阶段得出的假设，而实践是检验真理的唯一标准，所以我们一起做个实验，来证明我们刚才的假设吧。

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

const WIDTH = canvas.width;
const HEIGHT = canvas.height;

function randomColor() {
  return `hsl(${Math.random() * 360}, 100%, 50%)`;
}

function drawCircle(context, radius) {
  const x = Math.random() * WIDTH;
  const y = Math.random() * HEIGHT;
  const fillColor = randomColor();
  context.fillStyle = fillColor;
  context.beginPath();
  context.arc(x, y, radius, 0, Math.PI * 2);
  context.fill();
}

function draw(context, count = 500, radius = 10) {
  for(let i = 0; i < count; i++) {
    drawCircle(context, radius);
  }
}

requestAnimationFrame(function update() {
  ctx.clearRect(0, 0, WIDTH, HEIGHT);
  draw(ctx);
  requestAnimationFrame(update);
});

如上面代码所示，我们在 Canvas 上每一帧绘制 500 个半径为 10 的小圆，效果如下：

500个小球，半径10

注意，为了方便查看帧率的变化，我们在浏览器中开启了帧率检测。Chrome 开发者工具自带这个功能，我们在开发者工具的 Rendering 标签页中，勾选 FPS Meter 就可以开启这个功能查看帧率了。

我们现在看到，即使每帧渲染 500 个位置和颜色都随机的小圆形，Canvas 渲染的帧率依然能达到 60fps。

接着，我们增加小球的数量，把它增加到 1000 个。

1000个小球，半径10

这时你可以看到，因为小球数量增加一倍，所以帧率掉到了 50fps 左右，现在下降得还不算太多。而如果我们把小球的数量设置成 3000，你就能看到明显的差别了。

那如果我们把小球的数量保持在 500，把半径增大到很大，如 200，也会看到帧率有明显下降。

500个小球，半径200

但是，单从上图的实验来看，图形大小对帧率的影响也不是很大。因为我们把小球的半径增加了 20 倍，帧率也就下降到 33fps。当然这也是因为画圆比较简单，如果我们绘制的图形更复杂一些，那么大小的影响会相对显著一些。

通过这个实验，我们能得出，影响 Canvas 的渲染性能的主要因素有两点，一是绘制图形的数量，二是绘制图形的大小。这正好验证了我们开头的结论。

总的来说，Canvas2D 绘制图形的性能还是比较高的。在普通的个人电脑上，我们要绘制的图形不太大时，只要不超过 500 个都可以达到 60fps，1000 个左右其实也能达到 50fps，就算要绘制大约 3000 个图形，也能够保持在可以接受的 24fps 以上。

因此，在不做特殊优化的前提下，如果我们使用 Canvas2D 来绘图，那么 3000 个左右元素是一般的应用的极限，除非这个应用运行在比个人电脑的 GPU 和显卡更好的机器上，或者采用特殊的优化手段。那具体怎么优化，我会在下节课详细来说。

影响 SVG 性能的 2 大要素

讲完了 Canvas 接下来我们看一下 SVG。

我们用 SVG 实现同样的绘制随机圆形的例子，代码如下：

function randomColor() {
  return `hsl(${Math.random() * 360}, 100%, 50%)`;
}

const root = document.querySelector('svg');
const COUNT = 500;
const WIDTH = 500;
const HEIGHT = 500;

function initCircles(count = COUNT) {
  for(let i = 0; i < count; i++) {
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    root.appendChild(circle);
  }
  return [...root.querySelectorAll('circle')];
}
const circles = initCircles();

function drawCircle(circle, radius = 10) {
  const x = Math.random() * WIDTH;
  const y = Math.random() * HEIGHT;
  const fillColor = randomColor();
  circle.setAttribute('cx', x);
  circle.setAttribute('cy', y);
  circle.setAttribute('r', radius);
  circle.setAttribute('fill', fillColor);
}

function draw() {
  for(let i = 0; i < COUNT; i++) {
    drawCircle(circles[i]);
  }
  requestAnimationFrame(draw);
}

draw();

在我的电脑上（一台普通的 MacBook Pro，内存 8GB，独立显卡）绘制了 500 个半径为 10 的小球时，SVG 的帧率接近 60fps，会比 Canvas 稍慢，但是差别不是太大。

SVG绘制500个小球，半径10

当我们将小球数量增加到 1000 个时，SVG 的帧率就要略差一些，大概 45fps 左右。

SVG绘制1000个小球，半径10

乍一看，似乎 SVG 和 Canvas2D 的性能差别也不是很大。不过，随着小球数量的增加，两者的差别会越来越大。比如说，当我们将小球的个数增加到 3000 个左右的时候，Canvas2D 渲染的帧率依然保持在 30fps 以上，而 SVG 渲染帧率大约只有 15fps，差距会特别明显。

之所以在小球个数较多的时候，二者差距很大，因为 SVG 是浏览器 DOM 来渲染的，元素个数越多，消耗就越大。

如果我们保证小球个数在一个小数值，然后增大每个小球的半径，那么与 Canvas 一样，SVG 的渲染效率也会明显下降。

SVG绘制500个小球，半径200

如上图所示，当渲染 500 个小球时，我们把半径增加到 200，帧率下降到不到 20fps。

最终，我们能得到的结论与 Canvas 类似，影响 SVG 的性能因素也是相同的两点，一是绘制图形的数量，二是绘制图形的大小。但与 Canvas 不同的是，图形数量增多的时候，SVG 的帧率下降会更明显，因此，一般来说，在图形数量小于 1000 时，我们可以考虑使用 SVG，当图形数量大于 1000 但不超过 3000 时，我们考虑使用 Canvas2D。

那么当图形数量超过 3000 时，用 Canvas2D 也很难达到比较理想的帧率了，这时候，我们就要使用 WebGL 渲染。

影响 WebGL 性能的要素

用 WebGL 渲染上面的例子，我们不需要一个一个小球去渲染，利用 GPU 的并行处理能力，我们可以一次完成渲染。

因为我们要渲染的小球形状相同，所以它们的顶点数据是可以共享的。在这里我们采用一种 WebGL 支持的批量绘制技术，叫做 InstancedDrawing（实例化渲染）。在 OGL 库中，我们只需要给几何体数据传递带有 instanced 属性的顶点数据，就可以自动使用 instanced drawing 技术来批量绘制图形。具体的操作代码如下：

function circleGeometry(gl, radius = 0.04, count = 30000, segments = 20) {
  const tau = Math.PI * 2;
  const position = new Float32Array(segments * 2 + 2);
  const index = new Uint16Array(segments * 3);
  const id = new Uint16Array(count);

  for(let i = 0; i < segments; i++) {
    const alpha = i / segments * tau;
    position.set([radius * Math.cos(alpha), radius * Math.sin(alpha)], i * 2 + 2);
  }
  for(let i = 0; i < segments; i++) {
    if(i === segments - 1) {
      index.set([0, i + 1, 1], i * 3);
    } else {
      index.set([0, i + 1, i + 2], i * 3);
    }
  }
  for(let i = 0; i < count; i++) {
    id.set([i], i);
  }
  return new Geometry(gl, {
    position: {
      data: position,
      size: 2,
    },
    index: {
      data: index,
    },
    id: {
      instanced: 1,
      size: 1,
      data: id,
    },
  });
}

我们实现一个 circleGeometry 函数，用来生成指定数量的小球的定点数据。这里我们使用批量绘制的技术，一下子绘制了 30000 个小球。与绘制单个小球一样，我们计算小球的 position 数据和 index 数据，然后我们设置一个 id 数据，这个数据等于每个小球的下标。

我们通过 instanced:1 的方式告诉 WebGL 这是一个批量绘制的数据，让每一个值作用于一个几何体。这样我们就能区分不同的几何体，而 WebGL 在绘制的时候会根据 id 数据的个数来绘制相应多个几何体。

接着，我们实现顶点着色器，并且在顶点着色器代码中实现随机位置和随机颜色。

precision highp float;
attribute vec2 position;
attribute float id;
uniform float uTime;

highp float random(vec2 co) {
  highp float a = 12.9898;
  highp float b = 78.233;
  highp float c = 43758.5453;
  highp float dt= dot(co.xy ,vec2(a,b));
  highp float sn= mod(dt,3.14);
  return fract(sin(sn) * c);
}

vec3 hsb2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

varying vec3 vColor;

void main() {
  vec2 offset = vec2(
    1.0 - 2.0 * random(vec2(id + uTime, 100000.0)),
    1.0 - 2.0 * random(vec2(id + uTime, 200000.0))
  );
  vec3 color = vec3(
    random(vec2(id + uTime, 300000.0)),
    1.0,
    1.0
  );
  vColor = hsb2rgb(color);
  gl_Position = vec4(position + offset, 0, 1);
}

上面的代码中的 random 函数和 hsb2rgb 函数，我们都学过了，整体逻辑也并不复杂，相信你应该能看明白。

最后，我们将 uTime 作为 uniform 传进去，结合 id 和 uTime，用随机数就可以渲染出与前面 Canvas 和 SVG 例子一样的效果。

这个 WebGL 渲染的例子的性能非常高，我们将小球的个数设置为 30000 个，依然可以轻松达到 60fps 的帧率。

WebGL，绘制30000个小球，半径10

WebGL 渲染之所以能达到这么高的性能，是因为 WebGL 利用 GPU 并行执行的特性，无论我们批量绘制多少个小球，都能够同时完成计算并渲染出来。

如果我们增大小球的半径，那么帧率也会明显下降，这一点和 Canvas2D 与 SVG 一样。当我们将小球半径增加到 0.8（相当于 Canvas2D 中的 200），那么可以流畅渲染的数量就无法达到这么多，大约渲染 3000 个左右可以保持在 30fps 以上，这个效率仍比 Canvas2D 有着 5 倍以上的提升。小球半径增加导致帧率下降，是因为图形增大，片元着色器要执行的次数就会增多，就会增加 GPU 运算的开销。

好了，那我们来总结一下 WebGL 性能的要素。WebGL 情况比较复杂，上面的例子其实不能涵盖所有的情况，不过不要紧，我这里先说一下结论，你先记下来，我们之后还会专门讨论 WebGL 的性能优化方法。

首先，WebGL 和 Canvas2D 与 SVG 不同，它的性能并不直接与渲染元素的数量相关，而是取决于 WebGL 的渲染次数。有的时候，图形元素虽然很多，但是 WebGL 可以批量渲染，就像前面的例子中，虽然有上万个小球，但是通过 WebGL 的 instanced drawing 技术，可以批量完成渲染，那样它的性能就会很高。当然，元素的数量多，WebGL 渲染效率也会逐渐降低，这是因为，元素越多，本身渲染耗费的内存也越多，占用内存太多，渲染效率也会下降。

其次，在渲染次数相同的情况下，WebGL 的效率取决于着色器中的计算复杂度和执行次数。图形顶点越多，顶点着色器的执行次数越多，图形越大，片元着色器的执行次数越多，虽然是并行执行，但执行次数多依然会有更大的性能开销。最后，如果每次执行着色器中的计算越复杂，WebGL 渲染的性能开销自然也会越大。

总的来说，WebGL 的性能主要有三点决定因素，一是渲染次数，二是着色器执行的次数，三是着色器运算的复杂度。当然，数据的大小也会决定内存的消耗，因此也会对性能有所影响，只不过影响没有前面三点那么明显。

要点总结

要针对可视化的渲染效率进行性能优化，我们就要先搞清影响图形系统渲染性能的主要因素。

对于 Canvas 和 SVG 来说，影响渲染性能的主要是绘制元素的数量和元素的大小。一般来说，Canvas 和 SVG 绘制的元素越多，性能消耗越大，绘制的图形越大，性能消耗也越大。相比较而言，Canvas 的整体性能要优于 SVG，尤其是图形越多，二者的性能差异越大。

WebGL 要复杂一些，它的渲染性能主要取决于三点。

第一点是渲染次数，渲染次数越多，性能损耗就越大。需注意，要绘制的元素个数多，不一定渲染次数就多，因为 WebGL 支持批量渲染。

第二点是着色器执行的次数，这里包括顶点着色器和片元着色器，前者的执行次数和几何图形的顶点数有关，后者的执行次数和图形的大小有关。

第三点是着色器运算的复杂度，复杂度和 glsl 代码的具体实现有关，越复杂的处理逻辑，性能的消耗就会越大。

最后，数据的大小会影响内存消耗，所以也会对 WebGL 的渲染性能有所影响，不过没有前面三点的影响大。

小试牛刀

刚才我们用 SVG、Canvas 和 WebGL 分别实现了随机小球，由此比较了三种图形系统的性能。但是我们并没说 HTML/CSS，你能用 HTML/CSS 来实现这个例子吗？用 HTML/CSS 来实现，在性能方面与 SVG、Canvas 和 WebGL 有什么区别呢？从中，你能得出影响 HTML/CSS 渲染性能的要素吗？

在 WebGL 的例子中，我们采用了批量绘制的技术。实际上我们也可以不采用这个技术，给每个小球生成一个 mesh 对象，然后让 Ogl 来渲染。你可以试着用 Ogl 不采用批量渲染来实现随机小球，然后对比它们之间的渲染方案，得出性能方面的差异吗?

源码

课程中详细示例代码

29 | 怎么给Canvas绘制加速？

你好，我是月影。

上节课，我们从宏观上了解了各个图形系统在性能方面的优劣，以及影响性能的要素。实际上，想要解决性能问题，我们就必须要知道真正消耗性能的点，从而结合项目需求进行有针对的处理，否则性能优化就是纸上谈兵、空中楼阁。

所以这节课，我们就深入讨论一下影响 Canvas 绘图性能的因素，一起来分析几个不同类型的 Canvas 项目，找到的性能瓶颈以及对应的解决办法，从而学会对大部分 Canvas 项目进行性能优化。

我们知道，Canvas 是指令式绘图系统，它有状态设置指令、绘图指令以及真正的绘图方法（fill 和 stroke）等各类 API。通常情况下利用 Canvas 绘图，我们要先调用状态设置指令设置绘图状态，然后用绘图指令决定要绘制的图形，最后调用真正的 fill() 或 stroke() 方法将内容输出到画布上。

那结合上节课的实验我们知道，影响 Canvas 性能的两大因素分别是图形的数量和图形的大小。它们都会直接影响绘图指令，一个决定了绘图指令的多少，另一个决定了绘图指令的执行时间。通常来说，绘图指令越多、执行时间越长，渲染效率就越低，性能也就越差。

因此，我们想要对 Canvas 性能进行优化，最重要的就是优化渲染效率。常用的手段有 5 种，分别是优化 Canvas 指令、使用缓存、分层渲染、局部重绘和优化滤镜。此外，还有一种手段叫做多线程渲染，是用来优化非渲染的计算和交互方面导致的性能问题。

首先，我们来说说优化 Canvas 指令。

手段一：优化 Canvas 指令

刚刚我们说了，Canvas 执行的绘图指令越多，性能的消耗就越大。那如果希望 Canvas 绘图达到更好的性能，我们要尽可能减少绘图指令的数量。这就是“优化 Canvas 指令”要做的事情。

那具体怎么做呢？我们看一个例子。

假设，我们要在一个 600 X 600 的画布上，实现一些位置随机的多边形，并且不断刷新这些图形的形状和位置，效果如下：

结合我们之前学过的知识，这个效果其实并不难实现，可以分为 4 步，分别是创建多边形的顶点，根据顶点绘制图形，生成随机多边形，执行绘制。

具体的实现代码如下：

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

// 创建正多边形，返回顶点
function regularShape(x, y, r, edges = 3) {
  const points = [];
  const delta = 2 * Math.PI / edges;
  for(let i = 0; i < edges; i++) {
    const theta = i * delta;
    points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]);
  }
  return points;
}

// 根据顶点绘制图形
function drawShape(context, points) {
  context.fillStyle = 'red';
  context.strokeStyle = 'black';
  context.lineWidth = 2;
  context.beginPath();
  context.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    context.lineTo(...points[i]);
  }
  context.closePath();
  context.stroke();
  context.fill();
}

// 多边形类型，包括正三角形、正四边形、正五边形、正六边形和正100边形
const shapeTypes = [3, 4, 5, 6, 100];
const COUNT = 1000;

// 执行绘制
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for(let i = 0; i < COUNT; i++) {
    const type = shapeTypes[Math.floor(Math.random() * shapeTypes.length)];
    const points = regularShape(Math.random() * canvas.width,
      Math.random() * canvas.height, 10, type);
    drawShape(ctx, points);
  }
  requestAnimationFrame(draw);
}

draw();

这个效果实现起来虽然不难，但性能却不是很好，因为它在我的 Macbook Pro 电脑上只有不到 30fps 的帧率。那问题出在哪呢？我们还是要回到代码中。

我们注意到 drawShape 函数里的 for 循环，它是根据顶点来绘制图形的，一个点对应一条绘图指令。而在我们绘制的随机图形里，有 3、4、5、6 边形和 100 边形。对于一个 100 边形来说，它的顶点数量非常多，所以 Canvas 需要执行的绘图指令也会非常多，那绘制很多个 100 边形自然会造成性能问题了。因此，如何减少绘制 100 边形的绘图指令的数量，才是我们要优化的重点。具体该怎么做呢？

我们知道，对于半径为 10 的小图形来说，正 100 边形已经完全是正圆形了，所以我们可以用 arc 指令来替代 for 循环。

我们修改 shapeTypes 和 draw 函数，用 -1 代替正 100 边形，然后判断 type 是否大于 0，如果是就用之前的方式绘制正多边形，否则用 arc 指令来画圆。这么做了之后，整个效果的帧率就会从 30fps 提升到 40fps，效果还是比较明显的。

const shapeTypes = [3, 4, 5, 6, -1];
const COUNT = 1000;
const TAU = Math.PI * 2;

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for(let i = 0; i < COUNT; i++) {
    const type = shapeTypes[Math.floor(Math.random() * shapeTypes.length)];
    const x = Math.random() * canvas.width;
    const y = Math.random() * canvas.height;
    if(type > 0) {
      // 画正多边形
      const points = regularShape(x, y, 10, type);
      drawShape(ctx, points);
    } else {
      // 画圆
      ctx.beginPath();
      ctx.arc(x, y, 10, 0, TAU);
      ctx.stroke();
      ctx.fill();
    }
  }
  requestAnimationFrame(draw);
}

到这里，你会发现，我们讲的其实是个特例，那在实际工作中，我们是需要针对特例来优化的。我希望我讲完今天的内容你能够做到举一反三。

手段二：使用缓存

在上面的方法中，我们优化了绘图指令，让渲染性能有了比较明显的提升。不过，因为这个绘图任务的图形数量和状态都是有限的，我们还有更好的优化方法，那就是使用缓存。

因为 Canvas 的性能瓶颈主要在绘图指令方面，如果我们能将图形缓存下来，保存到离屏的 Canvas（offscreen Canvas）中，然后在绘制的时候作为图像来渲染，那我们就可以将绘制顶点的绘图指令变成直接通过 drawImage 指令来绘制图像，而且也不需要 fill() 方法来填充图形，这样性能就会有大幅度的提升。

具体的做法，是我们先实现一个创建缓存的函数。代码如下：

function createCache() {
  const ret = [];
  for(let i = 0; i < shapeTypes.length; i++) {
    // 创建离屏Canvas缓存图形
    const cacheCanvas = new OffscreenCanvas(20, 20);
    // 将图形绘制到离屏Canvas对象上
    const type = shapeTypes[i];
    const context = cacheCanvas.getContext('2d');
    context.fillStyle = 'red';
    context.strokeStyle = 'black';
    if(type > 0) {
      const points = regularShape(10, 10, 10, type);
      drawShape(context, points);
    } else {
      context.beginPath();
      context.arc(10, 10, 10, 0, TAU);
      context.stroke();
      context.fill();
    }
    ret.push(cacheCanvas);
  }
  // 将离屏Canvas数组（缓存对象）返回
  return ret;
}

然后，我们一次性创建缓存，直接通过缓存来绘图。

const shapes = createCache();
const COUNT = 1000;

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for(let i = 0; i < COUNT; i++) {
    const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
    const x = Math.random() * canvas.width;
    const y = Math.random() * canvas.height;
    ctx.drawImage(shape, x, y);
  }
  requestAnimationFrame(draw);
}

这样，我们就通过缓存渲染，把原本数量非常多的绘图指令优化成了只有 drawImage 的一条指令，让渲染帧率达到了 60fps，从而大大提升了性能。

缓存的局限性

不过，虽然使用缓存能够显著降低 Canvas 的性能消耗，但是缓存的使用也有局限性。

首先，因为缓存是通过创建离屏 Canvas 对象实现的，如果我们要绘制的图形状态（指不同形状、颜色等）非常多的话，那将它们都缓存起来，就需要创建大量的离屏 Canvas 对象。这本身对内存消耗就非常大，有可能反而降低了性能。

其次，缓存适用于图形状态本身不变的图形元素，如固定的几何图形，它们每次刷新只需要更新它的 transform，这样的图形比较适合用缓存。如果是经常发生状态改变的图形元素，那么缓存就必须一直更新，缓存更新本身也是绘图过程。因此，这种情况下，采用缓存根本起不到减少绘图指令的作用，反而因为增加了一条 drawImage 指令产生了更大的开销。

第三，严格上来说，从缓存绘制和直接用绘图指令绘制还是有区别的，尤其是在 fillText 渲染文字或者我们绘制一个图形有较大缩放（scale）的时候。因为不使用缓存直接绘制的是矢量图，而通过缓存 drawImage 绘制出的则是位图，所以缓存绘制的图形，在清晰度上可能不是很好。

但是总体来说，缓存的应用还是非常多的，我们应该要掌握它的用法，学会在合适的时候运用缓存来提升 Canvas 的渲染性能。

手段三：分层渲染

前面两种手段是操作 Canvas 上所有元素来优化性能的，但有的时候，我们要绘制的元素很多，其中大部分元素状态是不变的，只有一小部分有变化。这个时候，我们又该如何进行优化呢？

我们知道，Canvas 是将上一次绘制的内容擦除，然后绘制新的内容来实现状态变化的。利用这一特点，我们就可以将变化的元素和不变的元素进行分层处理。也就是说，我们可以用两个 Canvas 叠在一起，将不变的元素绘制在一个 Canvas 中，变化的元素绘制在另一个 Canvas 中。

我们还是来看一个例子。

假设，我们要实现一个如上图的效果。这个效果的特点是，画面上有一个飞机在运动，运动的物体比较少，而其他静止不动的图形很多（如背景中的上千个三角形）。

在绘制的时候，我们如果将运动的物体和其他物体都绘制在同一个 Canvas 画布中，要改变飞机的运动状态，我们就要重新绘制所有的物体，这会非常浪费性能。因此，更好的做法是我们使用两层画布，一层 Canvas 作为背景，来绘制静态的图形，就是这个例子里的上千个小三角形，而另一层 Canvas 作为前景，用来绘制运动的物体就是运动的飞机。

这样的话，我们只需要一次绘制就能得到背景层 Canvas，并且不管飞机的状态怎么改变，我们都不需要重绘，而前景的飞机可以每一帧重绘，也就大大减少了图形绘制的数量，并且提升了性能。

下面我列出具体的代码，虽然很长但逻辑并不复杂，核心就是用两个 Canvas 元素来分别绘制，你可以看一下。

function drawRandomTriangle(path, context) {
  const {width, height} = context.canvas;
  context.save();
  context.translate(Math.random() * width, Math.random() * height);
  context.fill(path);
  context.restore();
}

function drawBackground(context, count = 2000) {
  context.fillStyle = '#ed7';
  const d = 'M0,0L0,10L8.66, 5z';
  const p = new Path2D(d);
  for(let i = 0; i < count; i++) {
    drawRandomTriangle(p, context);
  }
}

function loadImage(src) {
  const img = new Image();
  img.crossOrigin = 'anonymous';
  return new Promise((resolve) => {
    img.onload = resolve(img);
    img.src = src;
  });
}

async function drawForeground(context) {
  const img = await loadImage('http://p3.qhimg.com/t015b85b72445154fe0.png');
  const {width, height} = context.canvas;
  function update(t) {
    context.clearRect(0, 0, width, height);
    context.save();
    context.translate(0, 0.5 * height);
    const p = (t % 3000) / 3000;
    const x = width * p;
    const y = 0.1 * height * Math.sin(3 * Math.PI * p);
    context.drawImage(img, x, y);
    context.restore();
    requestAnimationFrame(update);
  }
  update(0);
}

const bgcanvas = document.querySelector('#bg');
const fgcanvas = document.querySelector('#fg');
drawBackground(bgcanvas.getContext('2d'));
drawForeground(fgcanvas.getContext('2d'));

手段四：局部重绘

但是，我们用分层渲染解决性能问题的时候，所绘制的图形必须满足两个条件：一是有大量静态的图形元素不需要重新绘制，二是动态和静态图形元素绘制顺序是固定的，先绘制完静态元素再绘制动态元素。如果元素都有可能运动，或者动态元素和静态元素的绘制顺序是交错的，比如先绘制几个静态元素，再绘制几个动态元素，然后再绘制静态元素，这样交替进行，那么分层渲染就不好实现了。这时候，我们还有另外一种优化手段，它叫做局部重绘。

局部重绘顾名思义，就是不需要清空 Canvas 的全局区域，而是根据运动的元素的范围来清空部分区域。在很大一部分可视化大屏项目中，我们不会让整个屏幕的所有元素都不断改变，而是只有一些固定的区域改变，所以我们直接刷新那部分区域，重绘区域中的元素就可以了。

大屏的动态区与静态区

如上图所示，一个可视化大屏只有 2 块动态区域需要不断重绘，那我们用 Canvas 上下文的 clearRect 方法控制要刷新的动态区域，只对这些区域进行擦除然后重绘。

要注意的是，动态区重绘的时候，区域内的静态元素也需要跟着重绘。如果有静态元素跨越了动态和静态区域范围，那在重绘时，我们自然不希望破坏了静态区的图形。这时候，我们可以使用 Canvas 上下文的 clip 方法，它是一种特殊的绘图指令，可以设定一个绘图区，让图形的绘制限制在这个绘图区内部。这样的话，图形中超过 clip 范围的部分，浏览器就不会把它渲染到 Canvas 上。

这种固定区域的局部重绘使用起来不难，但有时候我们不知道具体的动态区域究竟多大。这个时候，我们可以使用动态计算要重绘区域的技术，它也被称为脏区检测。它的基本原理是根据动态元素的包围盒，动态算出需要重绘的范围。

那什么是包围盒呢？

我们知道，多边形由顶点构成，包围盒就是指能包含多边形所有顶点，并且与坐标轴平行的最小矩形。

多边形包围盒

在 Canvas 平面直角坐标系下，求包围盒并不复杂，只要分别找到所有顶点坐标中 x 的最大、最小值 xmin 和 xmax，以及 y 的最大、最小值 ymin 和 ymax，那么包围盒就是矩形[(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)]。

对所有的动态元素计算出包围盒，我们就能知道局部刷新的范围了。不过在实际操作的时候，我们经常会遇到各种复杂的细节问题需要解决。因为涉及的细节比较多，我没法全都讲到，所以，如果你遇到了问题，可以看看蚂蚁金服 AntV 团队的Canvas 局部渲染优化总结这篇文章。

手段五：优化滤镜

实际上，分层渲染和局部重绘解决的都是图形重绘的问题。那除了重绘，影响渲染效率的还有 Canvas 滤镜。

我们知道，滤镜是一种对图形像素进行处理的方法，Canvas 支持许多常用的滤镜。不过 Canvas 渲染滤镜的性能开销比较大。到底有多大呢？我们还是用前面绘制随机图形的例子来体验一下。

这次我们用缓存优化版本的代码，这一版代码的性能最高。在绘制前，我们给 Canvas 设置一个 blur 滤镜。代码如下：

ctx.filter = 'blur(5px)';

这样呢，我们让 Canvas 绘制出来的图形有了模糊的效果。但是这么设置了之后，你会发现原本 60fps 的帧率直接掉到 2fps，画面看上去一顿一顿的，卡得惨不忍睹。这就是因为滤镜对渲染性能的开销实在太大了。

那这种情况下，其实我们也有优化手段。针对这个场，我们实际上是对 Canvas 应用一个全局的 blur 滤镜，把绘制的所有元素都变得模糊，所以，我们完全没必要对每个元素应用滤镜，而是可以采用类似后期处理通道的做法，先将图形以不使用滤镜的方式绘制到一个离屏的 Canvas 上，然后直接将这个离屏 Canvas 以图片方式绘制到要显示的画布上，在这次绘制的时候采用滤镜。这样，我们就把大量滤镜绘制的过程缩减为对一张图片使用一次滤镜了。大大减少了处理滤镜的次数之后，效果立竿见影，帧率立即回到了 60fps。

那么具体实现的代码和效果我也列出来，你可以看一下。

ctx.filter = 'blur(5px)';

// 创建离屏的 Canvas
const ofc = new OffscreenCanvas(canvas.width, canvas.height);
const octx = ofc.getContext('2d');
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  octx.clearRect(0, 0, canvas.width, canvas.height);
  // 将图形不应用滤镜，绘制到离屏Canvas上
  for(let i = 0; i < COUNT; i++) {
    const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
    const x = Math.random() * canvas.width;
    const y = Math.random() * canvas.height;
    octx.drawImage(shape, x, y);
  }
  // 再将离屏Canvas图像绘制到画布上，这一次绘制采用了滤镜
  ctx.drawImage(ofc, 0, 0);
  requestAnimationFrame(draw);
}

draw();

当然这种优化滤镜的方式，只有当我们要对画布上绘制的所有图形，都采用同一种滤镜的时候才有效。不过，如果有部分图形采用相同的滤镜，而且它们是连续绘制的，我们也可以采用类似的办法，把这部分图形绘制到离屏 Canvas 上，之后再将图像应用滤镜并绘制回画布。这样也能够减少滤镜的处理次数，明显提升性能。总之，想要达到比较好的性能，我们要记住一个原则，尽量合并图形应用相同滤镜的过程。

手段六：多线程渲染

到这里，我们说完了几种提升渲染性能的常见手段。不过，影响用户体验的不仅仅是渲染性能，有时候，我们还要对绘制的内容进行交互，而如果渲染过程消耗了大量的时间，它也可能会阻塞其他的操作，比如对事件的响应。

遇到这种问题的时候，以前我们会比较头疼，甚至不得不降低渲染性能，以减少 CPU 资源占用，从而让交互行为不被阻塞。不过现在，浏览器支持的 Canvas 可以在 WebWorker 中以单独的线程来渲染，这样就可以避免对主线程的阻塞，也不会影响用户交互行为了。

那么具体怎么才能在 Worker 中绘制呢？其实也很简单。我们在浏览器主线程中创建 Worker，然后将 Canvas 对象通过 transferControlToOffscreen 转成离屏 Canvas 对象发送给 Worker 线程去处理。

const canvas = document.querySelector('canvas');

const worker = new Worker('./random_shapes_worker.js');
const ofc = canvas.transferControlToOffscreen();
worker.postMessage({
  canvas: ofc,
  type: 'init',
}, [ofc]);

这样，从使用上来说，无论在 Worker 线程中还是在主线程中操作都没有太大的区别，还能不阻塞浏览器主线程的任何操作。

我这里列出一部分核心代码，完整的代码我放在 GitHub 仓库里，你可以试着运行一下，看看效果。

function draw(ctx, shapes) {
  const canvas = ctx.canvas;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for(let i = 0; i < COUNT; i++) {
    const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
    const x = Math.random() * canvas.width;
    const y = Math.random() * canvas.height;
    ctx.drawImage(shape, x, y);
  }
  requestAnimationFrame(draw.bind(null, ctx, shapes));
}

self.addEventListener('message', (evt) => {
  if(evt.data.type === 'init') {
    const canvas = evt.data.canvas;
    if(canvas) {
      const ctx = canvas.getContext('2d');
      const shapes = createCache();
      draw(ctx, shapes);
    }
  }
});

要点总结

这节课我们讲了 Canvas 性能优化的 6 种手段，其中前 5 种是针对渲染效率进行优化，分别是优化 Canvas 指令、使用缓存、分层渲染、局部重绘，以及针对滤镜的优化。最后一种是通过多线程来优化计算的性能，让计算过程能够并行执行不会阻塞浏览器的 UI。下面，我再带你一起梳理一下性能优化的原则。

首先，我们在绘制图形时，用越简单的绘图指令来绘制，渲染的效率就越高。所以，我们要想办法减少 Canvas 绘图指令的数量，比如，用 arc 指令画圆来代替绘制边数很多的正多边形。

然后，当我们大批量绘制有限的几种形状的图形时，可以采用缓存将图形一次绘制后保存在离屏的 Canvas 中，下一次绘制的时候，我们直接绘制缓存的图片来取代原始的绘图指令，也能大大提升性能。

可如果我们绘制的元素中只有一部分元素发生改变，我们就可以采用分层渲染，将变化的元素绘制在一个图层，剩下的元素绘制在另一个图层。这样每次只需要重新绘制变化元素所在的图层，大大减少绘制的图形数，从而显著提升了性能。

还有一种情况是，如果 Canvas 只有部分区域发生变化，那我们只需要刷新局部区域，不需要刷新整个 Canvas，这样能显著降低消耗、提升性能。

还要注意的是，一些 Canvas 滤镜渲染起来非常耗费性能，所以我们可以对滤镜进行合并，让多个元素只应用一次滤镜，从而减少滤镜对性能的消耗。

最后，除了优化渲染性能外，我们还可以通过 WebWork 以多线程的手段优化计算性能，以达到渲染不阻塞 UI 操作的目的。

小试牛刀

学会了使用多种优化手段之后，我们来尝试实现一个粒子效果吧！

具体效果是，我们要让小三角形以不同的角度和速度，由画布中心点向四周运动，同时小三角形自身也以随机的角速度旋转。

你可以尝试用两种方式来实现这个效果，分别是使用性能优化和不使用性能优化。在这两种情况下，你的电脑最多能支持同时绘制多少个小三角形？

我们今天学的这 6 种性能优化手段，对你的工作是不是很有帮助？那不妨就把这节课分享出去吧！我们下节课再见！

源码

课程中详细示例代码 GitHub 仓库

推荐阅读

[1] AntV Canvas 局部渲染总结

[2] Speed up Your Canvas Operations with a Web Worker，WebWorker 和 OffscreenCanvas 使用参考文档

30｜怎么给WebGL绘制加速？

你好，我是月影。这节课，我们一起来讨论 WebGL 的性能优化。

WebGL 因为能够直接操作 GPU，性能在各个图形系统中是最好的，尤其是当渲染的元素特别多的时候，WebGL 的性能优势越明显。但是，WebGL 整体性能好，并不意味着我们用 WebGL 就能写出高性能代码来。如果使用方式不当，也不能充分发挥 WebGL 在性能方面的优势。

这节课，我们就重点来说说，怎么充分发挥 WebGL 的优势，让它保持高性能。

尽量发挥 GPU 的优势

首先，我们来想一个问题，WebGL 的优势是什么？没错，我强调过很多遍，就是直接操作 GPU。因此，我们只有尽量发挥出 GPU 的优势，才能让 WebGL 保持高性能。但这一点是很多习惯用 SVG、Canvas 的 WebGL 初学者，最容易忽视的。

为了让你体会到发挥 GPU 优势的重要性，我们先来看一个没有进行任何优化的绘图例子，再对它进行优化。

常规绘图方式的性能瓶颈

假设，我们要在一个画布上渲染 3000 个不同颜色的、位置随机的三角形，并且让每个三角形的旋转角度也随机。

常规的实现方法当然是用 JavaScript 来创建随机三角形的顶点，然后依次渲染。我在创建随机三角形顶点的时候，是使用向量角度旋转的方法创建了正三角形，我想这个方法你应该也不会陌生。

function randomTriangle(x = 0, y = 0, rotation = 0.0, radius = 0.1) {
  const a = rotation,
    b = a + 2 * Math.PI / 3,
    c = a + 4 * Math.PI / 3;

  return [
    [x + radius * Math.sin(a), y + radius * Math.cos(a)],
    [x + radius * Math.sin(b), y + radius * Math.cos(b)],
    [x + radius * Math.sin(c), y + radius * Math.cos(c)],
  ];
}

然后，我在下面代码的 for 循环中依次渲染每个三角形。

const COUNT = 3000;
function render() {
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();

    renderer.uniforms.u_color = [
      Math.random(),
      Math.random(),
      Math.random(),
      1];

    const positions = randomTriangle(x, y, rotation);
    renderer.setMeshData([{
      positions,
    }]);

    renderer._draw();
  }
  requestAnimationFrame(render);
}

render();

这里，我们只给着色器传入了一个颜色参数，其他的运算都是在 JavaScript 中完成的，所以对应的着色器代码非常简单。代码如下：

// 顶点着色器
attribute vec2 a_vertexPosition;

void main() {
  gl_Position = vec4(a_vertexPosition, 1, 1);
}

// 片元着色器
#ifdef GL_ES
precision highp float;
#endif

uniform vec4 u_color;

void main() {
  gl_FragColor = u_color;
}

这样，我们就完成了渲染 3000 个随机三角形的功能，效果如下：

你会发现，这样实现的图形性能很一般，因为 3000 个三角形渲染在普通笔记本电脑上只有 20fps，这大概和 Canvas2D 渲染出来的性能差不多，可以说完全没能发挥出 WebGL 应有的优势。

那我们应该对哪些点进行优化，从而尽量发挥出 GPU 的优势呢？

减少 CPU 计算次数

首先，我们可以不用生成这么多个三角形。根据前面学过的知识，我们可以创建一个正三角形，然后通过视图矩阵的变化来实现绘制多个三角形，而视图矩阵可以放在顶点着色器中计算。这样，我们就只要在渲染每个三角形的时候更新视图矩阵就行了。

具体来说就是，我们直接生成一个正三角形顶点，并设置数据到缓冲区。

const alpha = 2 * Math.PI / 3;
const beta = 2 * alpha;

renderer.setMeshData({
  positions: [
    [0, 0.1],
    [0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)],
    [0.1 * Math.sin(beta), 0.1 * Math.cos(beta)],
  ],
});

然后，我们用随机坐标和角度更新每个三角形的 modelMatrix 数据。

const COUNT = 3000;
function render() {
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();

    renderer.uniforms.modelMatrix = [
      Math.cos(rotation), -Math.sin(rotation), 0,
      Math.sin(rotation), Math.cos(rotation), 0,
      x, y, 1,
    ];

    renderer.uniforms.u_color = [
      Math.random(),
      Math.random(),
      Math.random(),
      1];

    renderer._draw();
  }
  requestAnimationFrame(render);
}

render();

而位置和角度的计算，我们放到顶点着色器内完成，代码如下：

attribute vec2 a_vertexPosition;

uniform mat3 modelMatrix;

void main() {
  vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
  gl_Position = vec4(pos, 1);
}

这么做了之后，三角形渲染的 fps 会略有提升，因为我们通过在顶点着色器中并行矩阵运算减少了顶点计算的次数。不过，这个性能提升在最新的 chrome 浏览器下可能并不明显，因为现在浏览器的 JavaScript 引擎的运算速度很快，尽管将顶点计算放到顶点着色器中进行了，性能差别也很微小。但不管怎么样，这种方法依然是可以提升性能的。

静态批量绘制（多实例绘制）

那有没有办法更大程度地提升性能呢？当然是有的。实际上，对于需要重复绘制的图形，最好的办法是使用批量绘制。重复图形的批量绘制，在 WebGL 中也叫做多实例绘制（Instanced Drawing），它是一种减少绘制次数的技术。

在 WebGL 中，一个几何图形一般需要一次渲染，如果我们要绘制多个图形的话，因为每个图形的顶点、颜色、位置等属性都不一样，所以我们只能一一渲染，不能一起渲染。但是，如果几何图形的顶点数据都相同，颜色、位置等属性就都可以在着色器计算，那么我们就可以使用 WebGL 支持的多实例绘制方式，一次性地把所有的图形都渲染出来。

多实例绘制的代码，其实我们在第 28 课里已经见过了。这里，我们再看一个例子，帮你加深印象。

首先，我们也是创建三角形顶点数据，然后使用多实例绘制的方式传入数据。因为 gl-renderer 中已经封装好了多实例绘制的方法，我们只需要传入 instanceCount 表示要绘制的图形数量即可。在原生的 WebGL 中使用多实例绘制会稍微复杂一点，我们一般不会这么做，但如果你想要尝试一下，可以参考这篇文章。

使用多实例绘制的代码如下：

const alpha = 2 * Math.PI / 3;
const beta = 2 * alpha;

const COUNT = 3000;
renderer.setMeshData({
  positions: [
    [0, 0.1],
    [0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)],
    [0.1 * Math.sin(beta), 0.1 * Math.cos(beta)],
  ],
  instanceCount: COUNT,
  attributes: {
    id: {data: [...new Array(COUNT).keys()], divisor: 1},
  },
});

这样，我们就只需要每帧渲染一次就可以了。为了能在顶点着色器中完成图形的位置和颜色计算，我们传入了时间 uTime 参数。代码如下：

function render(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(render);
}

render(0);

对应的顶点着色器如下：

attribute vec2 a_vertexPosition;
attribute float id;

uniform float uTime;

highp float random(vec2 co) {
  highp float a = 12.9898;
  highp float b = 78.233;
  highp float c = 43758.5453;
  highp float dt= dot(co.xy ,vec2(a,b));
  highp float sn= mod(dt,3.14);
  return fract(sin(sn) * c);
}

varying vec3 vColor;

void main() {
  float t = id / 10000.0;
  float alpha = 6.28 * random(vec2(uTime, 2.0 + t));
  float c = cos(alpha);
  float s = sin(alpha);

  mat3 modelMatrix = mat3(
    c, -s, 0,
    s, c, 0,
    2.0 * random(vec2(uTime, t)) - 1.0, 2.0 * random(vec2(uTime, 1.0 + t)) - 1.0, 1
  );
  vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
  vColor = vec3(
    random(vec2(uTime, 4.0 + t)),
    random(vec2(uTime, 5.0 + t)),
    random(vec2(uTime, 6.0 + t))
  );
  gl_Position = vec4(pos, 1);
}

我们这么做了之后，每一帧的实际渲染次数（即 WebGL 执行 drawElements 的次数）从原来的 3000 减少到了只有 1 次，而且计算都放到着色器里，利用 GPU 并行处理了，因此性能提升了 3000 倍。而且，现在不要说 3000，哪怕是 6000 个三角形，帧率都可以轻松达到 60fps 了，是不是很厉害？

动态批量绘制

可是，我又要给你泼一盆冷水了。虽然在绘制大量图形的时候，使用多实例绘制是一种非常好的方式，但是多实例渲染也有局限性，那就是只能在绘制相同的图形时使用。

不过，如果是绘制不同的几何图形，只要它们使用同样的着色器程序，而且没有改变 uniform 变量，我们也还是可以将顶点数据先合并再渲染，以减少渲染次数。

这么说你可能还不太理解，我们一起来看一个例子。假设，我们现在不只显示正三角形，而是显示随机的正三角形、正方形和正五边形。最常规的实现方式和前面显示随机正三角形的例子类似，我们只要修改一下顶点生成的函数，根据不同的边数生成对应的正多边形就可以了。代码如下：

function randomShape(x = 0, y = 0, edges = 3, rotation = 0.0, radius = 0.1) {
  const a0 = rotation;
  const delta = 2 * Math.PI / edges;
  const positions = [];
  const cells = [];
  for(let i = 0; i < edges; i++) {
    const angle = a0 + i * delta;
    positions.push([x + radius * Math.sin(angle), y + radius * Math.cos(angle)]);
    if(i > 0 && i < edges - 1) {
      cells.push([0, i, i + 1]);
    }
  }
  return {positions, cells};
}

这样，我们就可以随机生成三、四、五、六边形，代码如下：

const {positions, cells} = randomShape(x, y, 3 + Math.floor(4 * Math.random()), rotation);
renderer.setMeshData([{
  positions,
  cells,
}]);

不过这个例子的性能就更差了，渲染完 3000 个图形之后只有大概 5fps。当然这是正常的，因为正四边形、正五边形、正六边形每个分别要用 2、3、4 个三角形，所以虽然要绘制 3000 个图形，但我们实际绘制的三角形数量要远多于 3000 个。

而且，因为这些图形的形状不同，所以我们就不能使用多实例绘制的方式了。这个时候，我们又该如何优化呢？

我们依然可以将顶点合并起来绘制。因为每个图形都是由顶点（positions）和索引（cells）构成的，所以我们可以批量创建图形，将这些图形的顶点和索引全部合并起来。

function createShapes(count) {
  const positions = new Float32Array(count * 6 * 3); // 最多6边形
  const cells = new Int16Array(count * 4 * 3); // 索引数等于3倍顶点数-2

  let offset = 0;
  let cellsOffset = 0;
  for(let i = 0; i < count; i++) {
    const edges = 3 + Math.floor(4 * Math.random());
    const delta = 2 * Math.PI / edges;

    for(let j = 0; j < edges; j++) {
      const angle = j * delta;
      positions.set([0.1 * Math.sin(angle), 0.1 * Math.cos(angle), i], (offset + j) * 3);
      if(j > 0 && j < edges - 1) {
        cells.set([offset, offset + j, offset + j + 1], cellsOffset);
        cellsOffset += 3;
      }
    }
    offset += edges;
  }
  return {positions, cells};
}

如上面代码所示，我们首先创建两个类型数组 positions 和 cells，我们可以假定所有的图形都是正六边形，算出要创建的类型数组的总长度。注意，这里我们用的是三维顶点而不是二维顶点，这并不是说我们要绘制的图形是 3D 图形，而是我们使用 z 轴来保存当前图形的 id，提供给着色器中的伪随机函数使用。

计算顶点的方式和前面一样，都用的是向量旋转的方法。值得注意的是，在计算索引的时候，我们只要将之前已经算过的几何图形顶点总数记录下来，保存到 offset 变量里，从 offset 值开始计算就可以了。

最终，createShapes 函数会返回一个包含几万个顶点和索引的几何体数据，然后我们将它一次性渲染出来就行了。

const {positions, cells} = createShapes(COUNT);

renderer.setMeshData([{
  positions,
  cells,
}]);

function render(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(render);
}

render(0);

因为，对应的顶点着色器代码，与我们前面用多实例绘制的三角形例子差不多，只有一些微小的改动，所以你可以对比着看一下，加深理解。

attribute vec3 a_vertexPosition;
uniform float uTime;

highp float random(vec2 co) {
  highp float a = 12.9898;
  highp float b = 78.233;
  highp float c = 43758.5453;
  highp float dt= dot(co.xy ,vec2(a,b));
  highp float sn= mod(dt,3.14);
  return fract(sin(sn) * c);
}

varying vec3 vColor;

void main() {
  vec2 pos = a_vertexPosition.xy;
  float t = a_vertexPosition.z / 10000.0;

  float alpha = 6.28 * random(vec2(uTime, 2.0 + t));
  float c = cos(alpha);
  float s = sin(alpha);

  mat3 modelMatrix = mat3(
    c, -s, 0,
    s, c, 0,
    2.0 * random(vec2(uTime, t)) - 1.0, 2.0 * random(vec2(uTime, 1.0 + t)) - 1.0, 1
  );
  vColor = vec3(
    random(vec2(uTime, 4.0 + t)),
    random(vec2(uTime, 5.0 + t)),
    random(vec2(uTime, 6.0 + t))
  );
  gl_Position = vec4(modelMatrix * vec3(pos, 1), 1);
}

采用动态批量绘制之后，之前不到 5fps 的帧率，就被我们轻松提升到了 60fps。

批量渲染几乎是 WebGL 绘制最大的优化手段，因为它充分发挥了 GPU 的优势，所以能极大地提升性能。因此，在实际的 WebGL 项目中，如果我们遇到性能瓶颈，第一步就是要看看绘制的几何图形有哪些是可以批量渲染的，如果能批量渲染的，要尽量采用批量渲染，以减少一帧中的绘制次数。

不过批量渲染也有局限性，如果我们绘制的图形必须要用到不同的 WebGLProgram，或者每个图形要用到不同的 uniform 变量，那么它们就无法合并渲染。因此，我们在设计程序的时候，要尽量避免 WebGLProgram 切换，以及 uniform 的修改。

另外，在前面两个例子中，我们将 id 传入着色器，然后根据 id 在着色器中用伪随机函数计算位置和颜色。这样的好处自然是渲染起来特别快，但坏处是这些数据是在着色器中计算出来的，如果我们想从 JavaScript 中拿到一些有用信息，比如，图形的位置、颜色等等，就很难拿到了。

因此，如果业务中需要用到这些信息，我们就不能将它们放在着色器中计算。当然，我们可以通过 JavaScript 来计算位置和颜色信息，然后把它们写到 attribute 中。不过这样的话，我们使用的内存消耗就会增加一些，而且用 JavaScript 计算这些值的过程会比在着色器中略慢。当然这也是因为项目需求不得不做出的选择。

其他优化手段

好了，对性能影响最大的批量绘制我们讲完了。其实，还有两个因素对性能也有影响，分别是透明与反锯齿和 Shader 效率。下面，我也简单介绍一下。由于这些因素影响性能的原理相对比较简单，我就不举例来说了，你可以自己实践一下来加深理解。

透明度与反锯齿

首先，是透明与反锯齿。在 WebGL 中，我们要处理半透明图形，可以开启混合模式（Blending Mode）让透明度生效。只有这样，WebGL 才会根据 Alpha 通道值和图形的层叠关系正确渲染并合成出叠加的颜色值。开启混合模式的代码如下：

gl.enable(gl.BLEND);

不过，混合颜色本身有计算量，所以开启混合模式会造成一定的性能开销。因此，如果不需要处理半透明图形，我们尽量不开启混合模式，这样性能好就会更好一些。

此外，WebGL 本身对图形有反锯齿的优化，反锯齿可以避免图形边缘在绘制时出现锯齿，当然反锯齿本身也会带来性能开销。因此，如果对反锯齿的要求不高，我们在获取 WebGL 上下文时，关闭反锯齿设置也能减少开销、提升渲染性能。

const gl = canvas.getContext('webgl', {antiAlias: false}); //不消除反锯齿

Shader 的效率

最后，Shader 的效率也是我们在使用 WebGL 时需要注意的。我们前面说过，为了尽可能合并数据，动态批量绘制图形，我们要求图形尽量使用同一个 WebGLProgram，并且避免在绘制过程中切换 WebGLProgram。

但如果不同图形的绘制都使用同一个 WebGLProgram，这也会造成着色器本身的代码逻辑复杂，从而影响 Shder 的效率。最好的解决办法就是尽可能拆分不同的着色器代码，然后在绘制过程中根据不同元素进行切换。所以，批量绘制和简化 WebGLProgram 是一对矛盾，我们只能对两者进行取舍，尽可能让性能达到最优。

另外，shader 代码不同于常规的 JavaScript 代码，它最大的特性是并行计算，因此处理逻辑的过程与普通的代码不同。

那不同在哪儿呢？我们先来看一个常规的 JavaScript 代码。

if(Math.random() > 0.5) {
  do something
} else {
  do somthing else
}

我们都知道，如果 if 语句中的条件值为 true，那么第一个分支被执行，否则第二个分支被执行，这两个分支是不能同时被执行的。

但如果是 Shader 中的代码，情况就完全不同了。

if(random(st) > 0.5) {
  gl_FragColor = vec4(1)
} else {
  gl_FragColor = vec4(0)
}

无论是 if 还是 else 分支，在 glsl 中都会被执行，最终的值则根据条件表达式结果不同取不同分支计算的结果。

之所以会这样，就是因为 GPU 是并行计算的，也就是说并行执行大量 glsl 程序，但是每个子程序并不知道其他子程序的执行结果，所以最优的办法就是事先计算好 if 和 else 分支中的结果，再根据不同子程序的条件返回对应的结果。因此，if 语句必然要同时执行两个分支，但这样就会造成性能上一定的损耗，解决这个问题的办法是尽可能不用 if 语句。比如，对上面的代码，我们不用 if 语句，而是用 step 函数来解决问题，这样性能就会好一些。代码如下：

gl_FragColor = vec4(1) * step(random(st), 0.5);

此外，一些耗时的计算，比如开平方、反正切、反余弦等等，我们的优化原则也是能避免就尽可能避免，多使用简单的加法和乘法，这样就能保证着色器的高效率运行了。

要点总结

今天，我们重点讲了优化 WebGL 绘制性能的核心原则。

虽然 WebGL 是图形系统中渲染性能最高的，但如果我们不够了解 GPU，不对它进行有效的优化，就不能很好地发挥出 WebGL 的高性能优势。

用一句话总结，WebGL 的性能优化原则就是尽量发挥出 GPU 的优势。核心原则有两个：首先，我们尽量减少 CPU 计算次数，把能放在 GPU 中计算的部分放在 GPU 中并行计算；其次，也是更重要的，我们应该减少每一帧的绘制次数。

对应的优化方法也有两个：一是如果我们要绘制大量相同的图形，可以利用多实例渲染来实现静态批量绘制；二是如果绘制的图形不同，但是采用的 WebGL 程序相同、以及 uniform 的值没有改变，那我们可以人为合并顶点并进行渲染。减少绘制次数一般来说对性能会有比较明显的提升。

除此之外，我们还可以在不需要处理透明度的时候不启用混合模式，在不需要抗锯齿的时候关闭抗锯齿功能，它们都能减少性能开销。以及，我们还要注意 Shader 的效率，尽量用函数代替分支，避免一些耗时的计算，多使用简单的加法和乘法，这样能够保证着色器高效运行。

总的来说，性能优化是一个非常复杂的问题，我们应该结合实际项目的需求、数据的特征、技术方案等等综合考虑，最终才能得出最适合的方案。在实际项目中，无论你是直接用原生的 WebGL，还是使用 OGL、SpriteJS 或者 ThreeJS，大体的优化思路肯定离不开我前面总结的这些点。但怎么既恰到好处的优化，又保持性能与产品功能、开发效率以及扩展性的平衡，就需要我们通不断积累项目经验，才能慢慢做到最好啦。

小试牛刀

在前面的例子中，我们把位置和颜色的计算都放在了着色器中。这有利有弊，如果让你来重构代码，你能做到既兼顾性能，又能满足我们从 JavaScript 中拿到几何体位置和颜色的需求吗？如果可以，就快把你的解决方案写好分享出来吧。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程中完整示例代码

推荐阅读

WebGL2 系列之实例数组 (Instanced Arrays)

31 | 针对海量数据，如何优化性能？

你好，我是月影。

前两节课，我们一起学习了 Canvas2D 和 WebGL 性能优化的一些基本原则和处理方法。在正确运用这些方法后，我们能让渲染性能达到较高的程度，满足我们项目的需要。

不过，在数据量特别多的时候，我们会遇到些特殊的渲染需求，比如，要在一个地图上标记非常多的地理位置点（数千到数万），或者在地图上同时需要渲染几万条黑客攻击和防御数据。这些需求可能超过了常规优化手段所能达到的层次，需要我们针对数据和渲染的特点进行性能优化。

今天，我通过渲染动态地理位置的例子，来和你说说如何对特殊渲染需求迭代优化。不过，我今天用到特殊优化手段，只是一种具体的方法和手段，你可以借鉴他去理解思路，但千万不要陷入到思维定式中。因为解决这些特殊渲染需求，并没有固定的路径或方法，它是一个需要迭代优化的过程，需要我们对 WebGL 的渲染机制非常了解，并深入思考，才能创造出最适合的方法来。在我们实际的工作里，还有许多其他的方法可以使用，你一定要根据自己的实际情况随机应变。

渲染动态的地理位置

我先来看今天要实现的例子。在地图可视化应用中，渲染地理位置信息是一类常见的需求，例如在这张地图上，我们就用许多不同颜色的小圆点标注出了美国一些不同的地区。

如果我们要实现这些静态的标准点，方法其实很简单，用 Canvas2D 或者 WebGL 都可以轻松实现。就算点数量比较多也没关系，因为一次性渲染对性能影响也不会很大。不过，如果我们想让圆点运动起来，比如，做出一种闪烁或者呼吸灯的效果，那我们就要考虑点的数量对性能的影响了。

那面对这一类特殊的渲染的需求，我们该怎么办呢？下面，我们先用常规的做法来实现，然后在这个方法上不断迭代优化。为了方便你理解，我就不绘制地图了，只绘制这些随机的小圆点。

最简单的做法当然是一个一个圆绘制上去，也就是先创建圆的几何顶点数据，然后对每个圆设置不同的参数来分别绘制。实现代码如下：

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  uniform vec2 xy;
  uniform float uTime;
  uniform float bias;

  void main() {
    vec3 pos = vec3(a_vertexPosition, 1);
    float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
    mat3 m = mat3(
      scale, 0, 0,
      0, scale, 0,
      xy, 1
    );
    gl_Position = vec4(m * pos, 1);
  }
`;

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  uniform vec4 u_color;

  void main() {
    gl_FragColor = u_color;
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

function circle(radius = 0.05) {
  const delta = 2 * Math.PI / 32;
  const positions = [];
  const cells = [];
  for(let i = 0; i < 32; i++) {
    const angle = i * delta;
    positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
    if(i > 0 && i < 31) {
      cells.push([0, i, i + 1]);
    }
  }
  return {positions, cells};
}

const COUNT = 500;
function init() {
  const meshData = [];
  const {positions, cells} = circle();
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();
    const uniforms = {};

    uniforms.u_color = [
      Math.random(),
      Math.random(),
      Math.random(),
      1];

    uniforms.xy = [
      2 * Math.random() - 1,
      2 * Math.random() - 1,
    ];

    uniforms.bias = Math.random();

    meshData.push({
      positions,
      cells,
      uniforms,
    });
  }
  renderer.uniforms.uTime = 0;
  renderer.setMeshData(meshData);
}
init();

function update(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(update);
}

update(0);

上面的代码非常简单，关键思路就是用 circle 生成顶点信息，然后对每个需要绘制的圆应用 circle 顶点信息，并设置不同的 unifom 参数，最后在 shader 中根据参数进行绘制就可以了。

不过如果我们这么做的话，整体的性能就会非常低，比如在绘制 500 个圆的时候，浏览器的帧率就掉到十几 fps 了。那我们该怎么优化呢？

优化大数据渲染的常见方法

我们通过前面的学习已经知道渲染次数和每次渲染的顶点计算次数是影响渲染性能的要素，所以优化大数据渲染的思路方向自然就是减少渲染次数和减少几何体顶点数了。

1. 使用批量渲染优化

在学完 Canvas 和 WebGL 的性能优化之后，我们知道，在绘制大量同种几何图形的时候，通过减少渲染次数来提升性能最好的做法是直接使用批量渲染。针对今天的例子也是一样，我们稍微修改一下上面的代码，用实例渲染来代替逐个渲染，代码如下：

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  attribute vec4 color;
  attribute vec2 xy;
  attribute float bias;
  uniform float uTime;

  varying vec4 vColor;

  void main() {
    vec3 pos = vec3(a_vertexPosition, 1);
    float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
    mat3 m = mat3(
      scale, 0, 0,
      0, scale, 0,
      xy, 1
    );
    vColor = color;
    gl_Position = vec4(m * pos, 1);
  }
`;

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec4 vColor;

  void main() {
    gl_FragColor = vColor;
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

function circle(radius = 0.05) {
  const delta = 2 * Math.PI / 32;
  const positions = [];
  const cells = [];
  for(let i = 0; i < 32; i++) {
    const angle = i * delta;
    positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
    if(i > 0 && i < 31) {
      cells.push([0, i, i + 1]);
    }
  }
  return {positions, cells};
}

const COUNT = 200000;
function init() {
  const {positions, cells} = circle();
  const colors = [];
  const pos = [];
  const bias = [];
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();

    colors.push([
      Math.random(),
      Math.random(),
      Math.random(),
      1
    ]);

    pos.push([
      2 * Math.random() - 1,
      2 * Math.random() - 1
    ]);

    bias.push(
      Math.random()
    );
  }

  renderer.uniforms.uTime = 0;
  renderer.setMeshData({
    positions,
    cells,
    instanceCount: COUNT,
    attributes: {
      color: {data: [...colors], divisor: 1},
      xy: {data: [...pos], divisor: 1},
      bias: {data: [...bias], divisor: 1},
    },
  });
}
init();

function update(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(update);
}

update(0);

你可以比较一下上面的代码和前一个例子代码的差异，这里我们使用实例渲染将之前的 uniform 变量替换成 attribute 变量，其他的逻辑几乎不变。我们这么做了之后，即使渲染 100000 个点，浏览器的帧率也能达到 30fps 以上，性能提升了超过 2000 倍！

之所以批量渲染的性能比逐个渲染要高得多，是因为我们通过减少绘制次数，大大减少了 JavaScript 与 WebGL 底层交互的时间。不过，使用批量渲染绘制 20000 个点，就达到我们的性能极限了吗？显然没有，我们还可以运用其他的优化手段。

2. 使用点图元优化

绘制规则的图形，我们还可以使用点图元。还记得吗？我们说过 WebGL 的基本图元包括点、线、三角形等等。前面我们绘制圆的时候，都是用 circle 函数生成三角网格，然后通过三角形绘制的。这样，我们绘制一个圆需要许多顶点。但实际上，这种简单的图形，我们还可以直接采用点图元。

点图元造型

在 WebGL 中，点图元是最简单的图元，它用来显示画布上的点。在顶点着色器里，我们可以设置 gl_PointSize 来改变点图元的大小，所以我们就可以用点图元来表示一个矩形。我们看下面这个例子。

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  uniform vec2 uResolution;

  void main() {
    gl_PointSize = 0.2 * uResolution.x;
    gl_Position = vec4(a_vertexPosition, 1, 1);
  }
`;

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  void main() {
    gl_FragColor = vec4(0, 0, 1, 1);
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

renderer.uniforms.uResolution = [canvas.width, canvas.height];
renderer.setMeshData({
  mode: renderer.gl.POINTS,
  positions: [[0, 0]],
});

renderer.render();

如上面代码所示，我们将 meshData 的 mode 设为 gl.POINTS，只绘制一个点（0, 0）。在顶点着色器中，我们通过 gl_PointSize 来设置顶点的大小。由于 gl_PointSize 的单位是像素，所以我们需要传一个画布宽高 uResolution 进去，然后将 gl_Position 设为 0.2 * uResolution，这就让这个点的大小设为画布的 20%，最终在画布上就呈现出一个蓝色矩形。

注意，这里你可以回顾一下之前我们采用的常规的方法绘制的矩形，我们是将矩形剖分为两个三角形，然后用填充三角形来绘制的。而这里，我们用点图元，好处是我们只需要一个顶点就可以绘制，而不需要用四个顶点、两个三角形来填充。

现在，我们通过点图元，改变 gl_PointSize 绘制出了矩形，那怎么才能绘制出其他图形呢？实际上，答案在我们前面学过的课程里——使用距离场和造型函数。

在上面例子的基础上，我们修改一下顶点着色器和片元着色器。具体代码如下：

首先是顶点着色器代码。

attribute vec2 a_vertexPosition;

uniform vec2 uResolution;
varying vec2 vResolution;
varying vec2 vPos;

void main() {
  gl_PointSize = 0.2 * uResolution.x;
  vResolution = uResolution;
  vPos = a_vertexPosition;
  gl_Position = vec4(a_vertexPosition, 1, 1);
}

然后是片元着色器代码。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vResolution;
varying vec2 vPos;

void main() {
  vec2 st = gl_FragCoord.xy / vResolution;
  st = 2.0 * st - 1.0;
  float d = distance(st, vPos);
  d = 1.0 - smoothstep(0.195, 0.2, d);
  gl_FragColor = d * vec4(0, 0, 1, 1);
}

经过前面课程的学习，你应该对造型函数的实现原理比较熟悉了，这里我们就是通过计算到圆心的距离得出距离场，然后通过 smoothstep 将一定距离内的图形绘制出来，这样就得到一个蓝色的圆。

用这样的思路呢，我们就可以得到新的绘制大量圆的方法了。这种思路实现圆的代码如下：

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  attribute vec4 color;
  attribute float bias;

  uniform float uTime;
  uniform vec2 uResolution;

  varying vec4 vColor;
  varying vec2 vPos;
  varying vec2 vResolution;
  varying float vScale;

  void main() {
    float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
    gl_PointSize = 0.05 * uResolution.x * scale;
    vColor = color;
    vPos = a_vertexPosition;
    vResolution = uResolution;
    vScale = scale;
    gl_Position = vec4(a_vertexPosition, 1, 1);
  }
`;

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec4 vColor;
  varying vec2 vPos;
  varying vec2 vResolution;
  varying float vScale;

  void main() {
    vec2 st = gl_FragCoord.xy / vResolution;
    st = 2.0 * st - vec2(1);
    float d = step(distance(vPos, st), 0.05 * vScale);
    gl_FragColor = d * vColor;
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

const COUNT = 200000;
function init() {
  const colors = [];
  const pos = [];
  const bias = [];
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();

    colors.push([
      Math.random(),
      Math.random(),
      Math.random(),
      1
    ]);

    pos.push([
      2 * Math.random() - 1,
      2 * Math.random() - 1
    ]);

    bias.push(
      Math.random()
    );
  }

  renderer.uniforms.uTime = 0;
  renderer.uniforms.uResolution = [canvas.width, canvas.height];

  renderer.setMeshData({
    mode: renderer.gl.POINTS,
    enableBlend: true,
    positions: pos,
    attributes: {
      color: {data: [...colors]},
      bias: {data: [...bias]},
    },
  });
}
init();

function update(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(update);
}

update(0);

可以看到，我们没有采用前面那样通过 circle 函数来生成圆的顶点数据，而是直接使用 gl.POINTS 来绘制，并在着色器中用距离场和造型函数来画圆。这么做之后，我们大大减少了顶点的运算，原先我们每绘制一个圆，需要 32 个顶点、30 个三角形，而现在用一个点就解决了问题。这样一来，就算我们要渲染 200000 个点，帧率也可以保持在 50fps 以上，性能又提升了超过一倍！

其他方法

这里举上面的这个例子，主要是想说明一个问题：即使是使用 WebGL，不同的渲染方式，性能的差别也会很大，甚至会达到数千倍的差别。因此，在可视化业务中，我们一定要学会根据不同的应用场景来有针对性地进行优化。说起来简单，要做到这一点并不容易，你需要对 WebGL 本身非常熟悉，而且对于 GPU 的使用、渲染管线等基本原理有着比较深刻的理解。这不是一朝一夕可以做到的，需要持续不断地学习和积累。

就像有些同学使用绘图库 ThreeJS 或者 SpriteJS 来绘图的时候，做出来的应用性能很差，就会怀疑是图形库本身的问题。实际上，这些问题很可能不是库本身的问题，而是我们使用方法上的问题。换句话说，是我们使用的绘图方式并不是最适用于当前的业务场景。而 ThreeJS、SpriteJS 这些通用的绘图库，也并不会自己针对特定场景来优化。

因此，单纯使用图形库，我们绘制出来的图形就没法真正达到性能极致。也正是因为这个原因，我没有把这门课程的重点放在库的 API 的使用上，而是深入到图形渲染的底层原理。只有掌握了这些，你才能真正学会如何驾驭图形库，做出高性能的可视化解决方案来。

针对场景的性能优化方法其实非常多，我刚才讲的也只是几种典型的情况。为了帮助你在实战中慢慢领悟，我再举几个例子。不过我要提前说一下，我不会具体去讲这些例子的代码，只会重点强调常用的思路。学会这些方法之后，你再在实践中慢慢应用和体会就会容易很多了。

1. 使用后期处理通道优化

我们已经学习过使用后期处理通道的基本方法。实际上，后期处理通道十分强大，它最重要的特性就是可以把各种数据存储在纹理图片中。这样在迭代处理的时候，我们就可以用 GPU 将这些数据并行地读取和处理，从而达到非常高效地渲染。

这里是一个 OGL 官网上例子，它就是用后期处理通道实现了粒子流的效果。这样的效果，在其他图形系统中，或者 WebGL 不使用后期处理通道是不可能做到的。

这里的具体实现比较复杂，但其中最关键的一点是，我们要将每个像素点的速度值保存到纹理图片中，然后利用 GPU 并行计算的能力，对每个像素点同时进行处理。

2. 使用 GPGPU 优化

还有一种优化思路和后期处理通道很像，刚好 OGL 官网也有这个例子，它使用了一种叫做 GPGPU 的方式，也叫做通用 GPU 方式，就是把每个粒子的速度保存到纹理图片里，实现同时渲染几万个粒子并产生运动的效果。

3. 使用服务端渲染优化

最后一种优化思路，是从我之前做过的一个可视化项目中提取出来的。当时，我需要渲染数十万条历史数据的记录，如果单纯在前端渲染，性能会成为瓶颈。但由于这些数据都是历史数据，因此针对这个场景我们可以在服务端进行渲染，然后直接将渲染后的图片输出给前端。

要使用服务端渲染，我们可以使用 Node-canvas-webgl 这个库，它可以在 Node.js 中启动一个 Canvas2D 和 WebGL 环境，这样我们就可以在服务端进行渲染，然后再将结果缓存起来直接提供给客户端。

要点总结

这节课我们主要讲了针对业务的不同应用场景进行性能优化的思路和方法。尤其是在海量数据的情况下，特定优化手段显得十分重要，甚至有可能产生上千倍的性能差距。

结合今天的例子，对于要绘制大量圆形的场景，我们用常规的处理方法，渲染 500 个元素都比较吃力，一旦我们使用了批量渲染，就可以把性能一下子提升两千倍以上，能够轻松渲染 10 万个元素，如果我们再使用点图元结合造型函数的方法，就能轻松渲染 20 万个以上的元素了。像这样的优化方法，需要我们理解业务场景，并对 WebGL 和 GPU、渲染管线等有深入的理解，再在项目实践中慢慢积累。

除此以外，我们还简单介绍了其他的一些优化手段，包括使用后期处理通道、使用 GPGPU 和使用服务端渲染。你可以结合我给出的例子去深入理解。

小试牛刀

今天，我们使用点图元结合造型函数的思路，绘制了正方形和圆，你还能绘制出其他不同图形吗？比如圆、正方形、菱形、花瓣、苹果或葫芦等等。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程中完整示例代码

推荐阅读

[1] Post Fluid Distortion

[2] GPGPU Particles (General-Purpose computing on Graphics Processing Units)

[3] Node-canvas-webgl

32 | 数据之美：如何选择合适的方法对数据进行可视化处理？

你好，我是月影。

我们知道，可视化包括视觉和数据两大部分。通过前面的课程，我们完成了可视化中视觉呈现部分的学习，学会了用某种技术把数据展现给用户，产生丰富的、生动的、炫酷的视觉效果。今天，我们正式进入数据篇，开始学习数据处理。

你可能会问，学习可视化设计一定要学会处理数据吗？答案是一定要学。因为在可视化项目中，我们关注的信息经常会隐藏在大量原始数据中，而原始数据又包含了太过丰富的信息。其中大部分信息不仅对我们来说根本没用，还会让我们陷入信息漩涡，忽略掉真正重要的信息。

因此，只有深入去理解数据，学会提炼、处理以及合理地使用数据，我们才能成为一名优秀的可视化工程师。

那数据究竟是怎么从原始数据中获取的，又是怎么被我们用可视化的方式表达出来的呢？其实方法有很多，不过这节课我先举三种方法，让你对可视化数据处理手段有一个全面的认知，后几节课我们再深入讲解一些比较通用的数据处理技巧。

从原始数据中过滤出有用的信息

首先，我们明确一点，在可视化中，我们处理数据的目的就是，从数据中梳理信息，让这些信息反应出数据的特征或者规律。一个最常用的技巧就是按照某些属性对数据进行过滤，再将符合条件的结果展现出来，最终让数据呈现出我们希望用户看到的信息。

这么说可能还不太好理解，我们来看一个简单的例子。假设现在有一个小公园，公园有四个区域，分别是广场、休闲区、游乐场以及花园。每天上午 8 点、中午 12 点、下午 6 点以及晚上 8 点这四个时间，公园管理处会通过航拍收集 4 个区域上人群的分布信息，得到每天人群分布的数据之后，公园管理者就能够利用这些数据来优化公园的娱乐设施了。

具体该怎么做呢？利用可视化来解决这个问题会非常简单，思路就是先把人群的分布数据绘制成合适的可视化图表，从中分析出人群分布的规律。

这里，我仿造了一组人群数据，将它放在GitHub 仓库里，数据格式如下：

[{
  "x": 456,
  "y": 581,
  "time": 12,
  "gender": "f"
}, {
  "x": 293,
  "y": 545,
  "time": 12,
  "gender": "m"
}, {
  "x": 26,
  "y": 470,
  "time": 12,
  "gender": "m"
}, {
  "x": 254,
  "y": 587,
  "time": 12,
  "gender": "m"
}, {
  "x": 385,
  "y": 257,
  "time": 8,
  "gender": "m"
},
...]

数据是 JSON 格式，数组中的每一项表示一个游客，x、y 是拍摄位置，time 是时间，gender 是性别。

想要表现人群分布的规律，我们可以用这个数据来绘制一个散点图，它能非常直观呈现出原始数据。绘制方法非常简单，就是根据 x、y 坐标将一个小圆点标记在公园的某个位置上，代码如下：

function draw(data) {
  const context = canvas.getContext('2d');
  context.fillStyle = 'rgba(255, 0, 0, 0.5)';
  for(let i = 0; i < data.length; i++) {
    const {x, y} = data[i];
    context.beginPath();
    const spot = context.arc(x, y, 10, 0, Math.PI * 2);
    context.fill();
  }
}

fetch('data.json').then((res) => {
  return res.json();
}).then((data) => {
  draw(data);
});

最终绘制出来的效果如下图所示：

我们可以看到，这样绘制出来的分布图上显示了每天访问公园的人群，他们很均匀地分散在公园各处，似乎并没有什么特殊的地方。这其实是因为我们并没有根据其他属性来过滤这些数据。我们可以先试着根据时间来过滤。我们修改一下代码，给 draw 方法添加一个过滤函数。

function draw(data, filter = null) {
  if(filter) data = data.filter(filter);
  const context = canvas.getContext('2d');
  context.fillStyle = 'rgba(255, 0, 0, 0.5)';
  for(let i = 0; i < data.length; i++) {
    const {x, y} = data[i];
    context.beginPath();
    const spot = context.arc(x, y, 10, 0, Math.PI * 2);
    context.fill();
  }
}

fetch('data.json').then((res) => {
  return res.json();
}).then((data) => {
  draw(data, ({time}) => time === 8);
  // draw(data, ({time}) => time === 12);
  // draw(data, ({time}) => time === 18);
  // draw(data, ({time}) => time === 20);
});

把数据按照 8 点、12 点、18 点、20 点分别过滤之后，我们就能得到不同时间的游客散点图，如下图所示。

我们先看左上角，也就是 8 点钟的时候，游客大部分会集中在广场，结合这个时间点，他们可能是在晨练，而游乐场几乎没有游客，因为 8 点的时候游乐场还没开始营业。接着我们来看右上角，也就是中午 12 点，这个时候游客大部分集中在游乐场，说明此时是游乐场的高峰时间。然后是左下角，18 点的时候，你会发现一天中这个时间的游客数量是最多的，并且，集中在广场上的游客也最多，我们推测他们正在进行健身活动。最后，右下角也就是 20 点，这个时候公园临近关门，所以游客已经很少了。

就这样，我们得到了不同时间段，游客集中活动的场所。接下来，我们可以再把性别这个属性加上，看看还有什么分布规律，修改后的代码如下所示。我们用蓝色标记男游客，用红色标记女游客。

function draw(data, filter = null) {
  if(filter) data = data.filter(filter);
  const context = canvas.getContext('2d');
  for(let i = 0; i < data.length; i++) {
    const {x, y, gender} = data[i];
    context.fillStyle = gender === 'f' ? 'rgba(255, 0, 0, 0.5)' : 'rgba(0, 0, 255, 0.5)';
    context.beginPath();
    const spot = context.arc(x, y, 10, 0, Math.PI * 2);
    context.fill();
  }
}

fetch('data.json').then((res) => {
  return res.json();
}).then((data) => {
  draw(data, ({time}) => time === 12);
});

标记完我们再来看一下 12 点的游客散点图。

我们看到，集中再游乐场和休闲区的主要是男游客，而女游客更喜欢呆在花园。这可能是和游乐场的游乐设施以及休闲区的设计有关。

到这里，我们关于公园游客的可视化分析就告一段落了。通过我们分析得出的规律，对游乐场改进游乐设施和日常管理是有实际的参考作用。

因为这里的数据是我仿造的数据，所以不一定符合真实情况，不过这并不重要。通过这个例子，我主要是想让你体会数据可视化分析的一般过程，通常我们通过数据过滤和展示，从中提取出有用信息，以便于做出后续的决策，这就是数据可视化的价值所在。只要数据是客观的，分析过程是合理的，那数据表现出来的结果就是具有实际意义的。

强化展现形式让用户更好地感知

在前面的例子里，我们用散点图呈现游客信息并从中分析出有用的内容，这种形式直观有效，但是展现形式略显单调。除了合理的数据分析以外，数据可视化有时候通过强化展现形式，让用户更好地感知数据表达的内容。这样能够帮助需要关注该数据的用户，更好地把握整体信息。

我在 GitHub 看到一个非常合适的例子，我们来看一下。

空气质量和我们生活质量息息相关，那在过去的几年里，雾霾和蓝天交替，成为我们生活的一部分。近几年来，国家一直在大力治理空气污染。那在这种情况下，我们的空气质量到底有没有变好呢？

一名亚赛同学写了一个北京空气质量（2015-2018）的可视化展现，利用每天北京空气的 AQI 值绘制了色条，他还用心地让每一天对应了一个地标的当天实拍照片。这不仅增加了项目整体的趣味性，也强化了用户的直观认知。

北京空气质量（2015-2018）可视化展现（图片来源：wangyasai.github.io)

如上面示意图所示，我们将每一年的数据按照 AQI 排序后，可以明显看出灰色的区域在逐年减少，这说明北京的空气质量的确是在逐年好转的。

这个项目的代码在亚赛同学的GitHub 仓库里，我就不拿出来细讲了。因为这个可视化效果的实现原理并不复杂，而且这节课我们更应该学习和理解用数据进行可视化展现的思路，而不是代码实现细节。当然，如果你想搞懂代码，那你可以深入分析一下 GitHub 仓库里的源码。这个项目代码具体实现是依赖一个叫做 p5.js 的图形库，它也是一个很棒也很有趣的图形库，用来学习可视化也非常合适，如果你有兴趣可以去看一下。

如果你想要亲自动手实现一个这样的可视化项目，我也建议你借鉴亚赛同学这个项目中的数据和思路，对它进行一些改进。

将信息的特征具象化

到现在为止，你可能认为可视化都是需要使用真实数据来呈现的，数据越真实、越详细，可视化效果呢就越好。如果有了这个想法，说明你有一点陷入到思维定式中了。实际上，有时候我们并不要求数据越真实越详细，甚至不要求绝对真实的数据，只需要把数据的特征抽象和提取出来，再把代表数据最鲜明的特征，用图形化、令人印象深刻的形式呈现出来，这就已经是成功的可视化了。

比如下面这张图，就用可视化的方式解释了数据、信息、知识、见解、智慧和阴谋论。这种可视化呈现的数据并不是真实、准确地，而是带有趣味性的，通过对信息特征进行抽取，让看的人形成了一种视觉认知。

数据-信息-知识-洞见-智慧-影响力(图片来源：gapingvoid)

其实这样的可视化例子还有很多，比如 Matt Might 教授绘制的“图解博士是什么”也非常有趣。由于图片很长，我在这里就不列出来了。虽然它的数据不是基于海量数据提取的，但却是一组概念的具象化，所以它毫无疑问也是一个非常成功的可视化方案例。

除此之外，Manu Cornet 的组织架构图，也用非常形象的方法绘制出了各个知名公司的组织架构差异。它的数据当然也不是各个公司详细的组织架构数据，而是根据每个公司组织架构特征直接图形化形成的。

(图片来源：bonkersworld)

看了前面这些例子，你可能会有疑问，第三种方法似乎和原始数据并没有关联，而是直接用信息特征来完成的可视化，那第一、二种方法和数据处理过程和它又有什么关系呢？

实际上，我们使用第三种方法，也就是信息特征具象化的前提，是我们真正掌握了我们需要的信息特征，而这些特征的提取和掌握，正是通过前面两种方法迭代出来的！用一句话总结就是，数据可视化本身是一个不断迭代的过程。

具体过程是，我们先进行原始数据的信息收集和分类处理，再通过原始方法表达出有用的信息，接着通过强化展现形式，让信息的核心特征变得更加鲜明，经过这一轮或者几轮的迭代，我们就可能拿到最本质的信息了，最终我们再把这些信息具象化，就可以达到令人印象深刻的效果了。

所以，对原始数据进行不断迭代，就是数据可视化的基本方法论。我希望你能牢牢记住这句话，并且在实践中认真去做。

要点总结

这节课，我们主要讲了对数据进行可视化处理的三种常见方法。

第一种，是从原始数据中过滤出有用的信息。这是数据可视化处理的第一步，也是最基础的方法。第二种，是强化数据的展现形式，让用户更好地感知我们要表达的信息。这是我们在第一步的基础上对数据进行的加工处理。而第三种，是把数据的特征具象化，然后用图形表达。这是我们在第一、二步的基础上，对数据进一步的抽象和提取。如果达到这一步，我们甚至有可能完全脱离原始数据，不依赖原始数据，而是着眼于数据特征的表现形式。

这三种方法层层递进，是数据可视化的基本方法论，而数据可视化本身，其实就是使用这些方法对数据信息进行不断迭代和构建的过程。

小试牛刀

你能借助我们前面说的北京空气质量，这个可视化例子中的代码，实现一个你想要的可视化展现吗？你可以不完全按照亚赛同学的方法，多加些自己的创意，以及我们前面学过的图形学技巧，相信你能做出非常好的效果。当然了，我也知道这个挑战有点难，但整个实现的过程能让你把学到的图形学知识融会贯通，所以我还是建议你尝试一下。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程中完整实例代码

推荐阅读

[1] 北京空气质量（2015-2018）的可视化展现

[2] 图解博士是什么

33 | 数据处理（一）：可视化数据处理的一般方法是什么？

你好，我是月影。

在数据处理的过程中，我们经常遇到两种情况：一种是数据太少，我们没法找到有用的信息，也就无法进行可视化呈现。另一种是数据太多，信息纷繁复杂，我们经常会迷失在信息海洋中，无法选择合适的可视化呈现方式，最终也表达不了多少有意义的内容。

那你可能想问了，想要解决这两种情况，我们能用上节课讲过的三种数据处理方法吗？事实上，上节课的方法是数据可视化的基本方法论，你可以在可视化过程中借鉴它们的思路，但是它们并不系统。

因此，我们在探索数据可视化的时候，还需要一个合理的数据可视化分析过程作为参照。从这一节课开始，我们就来系统地讨论数据处理的一般方法。

数据可视化的一般过程

针对课程一开始这两种情况，就算是不学数据处理的一般思路，我们也知道，如果你的数据太少，你要想办法获取更多的数据，而如果你的数据太多，那你就需要学会运用正确的方法不断迭代、筛选。而且，数据过多的情况我们遇到得更多。

当你学会在众多复杂的数据中准确地抽取信息，把这些数据的某一面可视化出来的时候，你就已经能够轻松地从数据中得到你想要的内容。通过这个过程，有可能让你从数据的一面获得启发，从而发现数据其他方面的有趣内容，进而产生出更多不同的图表，让数据呈现出更多的意义。

所以在数据可视化中，你有什么样的数据其实是最重要的，而我们的可视化手段会随着具体数据集的不同而不同。因此，我们一般会围绕 4 个问题对可视化过程进行迭代，它们分别是你有什么数据？你想从数据中了解什么信息？你想用什么样的可视化方式呈现？以及你看到了什么，它有意义吗？并且，前一个问题的答案都会影响后一个问题的答案。

结合这几个问题，我把数据可视化的一般过程用一个流程图画了出来。接下来，我们就分别说说每一步具体是怎么操作的。

可视化迭代过程

你有什么数据？

我们在实际处理可视化数据的时候，经常会遇到可视化项目最终产出的结果与设计预期和客户的期望相差甚远。

比如说，产品经理经常会提出一些需求，可能是要我们实现某种复杂的可视化大屏，或者指定一些竞品中的图表形式让我们模仿，有时候甚至会在数据给到我们之前先完成 UI 设计。可是，等到我们最终拿到数据之后，却发现数据的信息不足以支撑这些图表的展现形式，或者数据的内容在这些图表上表现得不好。

其实这不是产品经理能力不足、也不是可视化工程师能力不足，而是我们一开始就搞错了步骤，我们应该先分析真实数据，找到数据的特点，比如我们可以按照时间、地点、性别对数据进行分类。然后，我们再研究具体的数据呈现形式。因此，做可视化项目的第一步，就是要先了解自己掌握的数据，而不同的数据要了解的内容不同，我们要根据实际情况来具体分析。

你想从数据中了解到什么信息？

在得到并且了解原始数据之后，接下来我们该怎样着手获取想要的信息呢？我们以第 27 节课用到的 GitHub 贡献数据为例。当时我们想实现 GitHub 用户代码贡献图表，GitHub 贡献数据已经被平台加工过，平台已经把每个用户的贡献信息整理出来了，所以这类数据非常简单，我们直接用就可以了。

不过，我们可以想象一下，GitHub 贡献数据的原始数据长什么样。我认为，它应该包含了每个 GitHub 代码仓库提交的日志，并且每一条日志对应一个提交人和一个提交时间。要把这些数据整理成 GitHub 用户代码贡献数据，GitHub 平台需要对这些原始数据按日期和提交人进行归总，统计出每个用户在具体某个日期的代码提交次数。

假设，现在我们不想要统计每个用户每天的代码贡献数据，而是要统计每个代码仓库每天的代码贡献数据。那么，我们只需要对原始数据换一种方式处理，将日志不按照提交人进行归总，而是按照提交的仓库进行归总。这样我们就可以得到数据的另一面，从而得到一个完全不同的可视化内容，也就是代码仓库每日的代码贡献次数。

所以，当我们确定了想要表达的信息之后，如果数据中有，我们就直接拿来用，如果没有，我是需要自己来处理的。

使用何种可视化方式呈现？

当确定了想要了解的信息之后，会有很多视觉呈现方式供我们选择。那我们该如何选择呢？核心原则就是一条：当为数据选择正确的呈现形式时，我们要学会从不同的角度思考数据，把握住最希望被用户关注到的那些内容，然后用直观的、有趣的以及更加印象深刻的方式来进行表达。

你可能会说，道理我都懂，可具体该怎么做呀？

其实具体怎么做，并没有定论，我们只能通过例子慢慢积累经验，随着经验的丰富，你就能慢慢找到设计合适的可视化方式的感觉。比如说，现在你就可以想想，我在下面给出的这个例子。例子中给出的数据图表是 Tim De Chant 的世界人口地图。其中一共有 6 个城市，每个城市的人口密度不同，如果我们要把全球 70 亿人都放在任意密度的城市中，这个城市会有多大呢？

我的建议你可以好好想到底怎么做，有机会的话一定要去实践。在实践的过程中，当你尝试不同的标尺、颜色、形状、大小和几何图形的时候，就会看到可以值得探索的视觉呈现形式了。另外，给数据增加直观性和趣味性，也能够让朴实的数据立刻生动起来。这也是实践你学过的视觉呈现技术的最好时机。

你看见了什么？它有意义吗？

可视化数据之后，我们需要从中挖掘出一些有价值的信息，包括但不限于数据信息的规律（增加、减少、离群值等），以及数据信息的异常（明显差异、规律改变）等等，然后将这些规律和异常归因，帮助我们真正了解数据背后有价值的内容。这些正是可视化的意义所在。

客观的数据是有说服力的，因为客观数据一旦产生就不会改变，所以，我们一般认为这样的数据就是事实。但有用的信息往往隐藏得比较深，因此，数据可视化最终目的就是将这些信息挖掘出来，呈现在人们眼前。

到这里，数据可视化的一般过程我们就讲完了。总之，在对数据进行四个步骤的迭代过程中，不同的数据以及不同的数据处理方式，在迭代中会产生不同的呈现形式。为了让你更深刻地理解这一点，我就以上节课游乐场的数据为例，带你体验一次数据处理的全过程。

实战演练：对公园中的游客进行数据可视化

首先我们来看我们有什么数据。我们的原始数据的格式就像下面记录的一样，有时间、地点和性别。

[{
  "x": 456,
  "y": 581,
  "time": 12,
  "gender": "f"
}, {
  "x": 293,
  "y": 545,
  "time": 12,
  "gender": "m"
}, {
...
}]

接下来我们看一下我们想了解什么，假设我们想了解公园一天中的游客变化规律，那么我们可以用分类的思路处理数据。在上节课，我们就对数据进行了简单的时间分类、地点分类和性别分类。这里，我们用 d3 的数据变换（Transformations）模块将原始数据处理成我们想要的模式。

这里，我详细说说我们用到的d3.rollups ，它可以对数据进行分组，然后汇总。这个接口设计得比较函数式（functional)，它接受 3 个参数，第一个参数是要处理的数据，也就是上面的原始数据，后面两个参数是两个函数算子，第一个算子表示对数据分组进行汇总的方式，这里是使用 length 来汇总，也就是统计数据的条目数。第二个算子则表示对数据进行分组的属性，这里是用时间属性进行分组。最后，我们在分组之后，再对数据进行一次排序，因为我们要按照时间从小到大进行排序。

(async function() {
  const data = await (await fetch('data.json')).json();
  const dataset = d3.rollups(data, v => v.length, d => d.time).sort(([a],[b]) => a - b);
  ...
}());

经过分组和排序之后，我们从原始数据得到了如下的新数据：

[[8, 145], [12, 141], [18, 191], [20, 23]]

现在，我们只有 8 点、12 点、18 点、20 点这 4 个时间段的数据，我们还需要把游客为 0 的时间信息补全。假设公园是早晨 6 点开门，晚上 22 点关门，那么 6 点、22 点的游客数应该是 0，补充的数据如下：

dataset.unshift([6, 0]);
dataset.push([22, 0]);

我们补全的数据是一个二维数组，其中每个元素的第一个值是时间，第二个值是当前时间的公园内人数。

接着，我们就要进行第三步了：确定用哪种可视化方式呈现数据。因为要呈现游客的变化规律，所以我们最终决定使用折线图来呈现，那我们就要把数据转换成要显示的折线上的点坐标。

const points = [];
dataset.forEach((d, i) => {
  const x = 20 + 20 * d[0];
  const y = 300 - d[1];
  points.push(x, y);
});

然后，我们用 SpriteJS 创建 Polyline 元素，把这个折线点坐标传给它。最后，我们把这个元素给添加到 layer 上，就可以将它显示出来了。

const p = new Polyline();
p.attr({
  points,
  lineWidth: 4,
  strokeColor: 'green',
  smooth: true,
});

fglayer.append(p);

因为还要考虑到游客是随时间变化的，所以我们要给它增加一个坐标轴。这里，我们是用 SpriteSvg 来绘制坐标轴的，SpriteSvg 是一个特殊元素，它能够创建一个 SVG 对象，然后把它以图像方式绘制到 Canvas 上。你发现了没有，这就是我们前面说过的 SVG 和 Canvas 的混合使用方式，它可以把 SVG 作为图像绘制，这样既能使用 SVG 来灵活地改变图形，又可以用 Canvas 来高性能地渲染。

const scale = d3.scaleLinear()
  .domain([0, 24])
  .range([0, 480]);

const axis = d3.axisBottom(scale)
  .tickValues(dataset.map(d => d[0]));

const axisNode = new SpriteSvg({
  x: 20,
  y: 300,
  flexible: true,
});

d3.select(axisNode.svg)
  .attr('width', 600)
  .attr('height', 60)
  .append('g')
  .call(axis);

axisNode.svg.children[0].setAttribute('font-size', 20);
fglayer.append(axisNode);

在创建坐标轴的时候，我们需要给坐标轴设置一个 scale，d3 中间应用了很多函数式编程思想，这里的 scale 也是一个函数算子，它是由高阶函数 d3.scaleLinear 创建的，我们给它设置 domain 从 0 到 24，表示一天的 24 个小时，range 从 0 到 480，表示占据 480 像素宽度。

然后，我们再通过 d3.axisBottom 高阶函数，用创建的 scale 来生成一个具体的坐标轴算子 axis，然后对 SVG 对象应用这个算子，就可以绘制出坐标轴的图像了。最终的效果如下图所示：

公园24小时游客人数变化图

最后，我们能从这张图上看出些什么信息呢？在这张图上，我们看到公园的游客数有两个高峰期，分别是早 8 点和傍晚 6 点，而 12 点的游客是比较少，晚上 8 点之后到 10 点闭园之前游客的数量是最少的。

这样，我们就得到了一天中游园人数的变化趋势，这对公园的管理策略是有一些参考价值的。这个数据还很粗糙，因为我们的原始数据的信息量并不大。不过如果我们继续收集不同天的数据，并进一步分析游客在不同日期、不同地点的人数情况，或者对游客的性别进行分析，我们就可能得到更多有趣的信息，但究竟能获得什么样的有用信息，还要看原始数据情况和我们实际着手进行迭代的方式。

总之，当我们把更多的信息集中在一起的时候，我们就能做更多的事情了，比如，我们可以分析游客数和当月平均气温的关系，或者分析交通趋势和公园游客趋势的关系，以及分析天气与游园游客性别的相关性等等，并且我们还能利用这些数据来帮助公园后续的建设和管理决策。

要点总结

这节课，我们讲了数据可视化的一般过程，它是一个需要反复迭代的过程，而且在每一轮迭代中，我们都可以以这 4 个问题作为参照，分别是你有什么数据？你想从数据中了解什么信息？你想用哪种可视化方式呈现？你看见了什么，它有意义吗？

按照这四个问题，我们在迭代过程中可以用分类的方式来处理数据，对数据分类，是最常用的处理原始数据的方式，也符合我们的思维习惯。数据的分类可以带来信息结构化，从而帮助我们提取想要的内容。也就能一步步达成我们想要的结果，然后我们再逐步细化迭代，就能做出更好的可视化效果来。

最后，我还想再强调一下，从本质上来说，可视化过程是对数据进行分析、提取有效信息、设计展现形式的不断迭代过程，今天我们讲的例子虽然简单，但更加复杂的例子，也无外乎是同样的处理过程，只不过那个时候，我们可能需要处理更多的数据，经过更多轮迭代。

小试牛刀

我们的原始数据里有性别数据，你可以根据性别数据进行过滤，修改上面的例子，看看按照性别分类的游客趋势图与不按性别分类的游客趋势图有什么不同吗？或者，除了折线图，你能把每个时间段的游客性别比例用并饼状图显示出来吗？以及，你还可以试着按照公园地点进行分组，再显示更细分的饼图效果。

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程中完整示例代码 GitHub 仓库

34 | 数据处理（二）：如何处理多元变量？

你好，我是月影。

数据处理是一门很深的学问，想要学好它，我们不仅需要掌握很复杂的理论，还需要不断地积累经验。不过，其中也有一些基础的数据处理技巧，掌握它们，我们就能更好地入门可视化了。

比如我们上节课重点讲解的数据分类，就是其中一种非常基础的数据处理技巧，也是数据处理的第一步。这一节课，我会以处理 2014 年北京市的天气历史数据为例，来和你进一步讨论数据处理的基础技巧，包括从数据到图表的展现以及处理多元变量的方法。

从数据到图表展现

一般来说，我们拿到的原始数据通常可以组织成表格的形式，表格中会有很多列，每一列都代表一个变量。比如，我们拿到的这份天气历史数据，它看起来可能是下面这样的：

这里有许多变量，比如时间、最高气温、平均气温、最低气温、最高湿度、平均湿度、最低湿度、露点等等。一般的情况下，我们会将其中我们最关心的一个变量平均气温，用一个图表展现出来。具体怎么做呢？我们可以来动手操作一下。

这份数据是 csv 格式的，是一张表，我们先用 D3.js 将数据读取出来，然后结构化成 JSON 对象。

const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData);
const dataset = data.filter(d => new Date(d.Date).getMonth() < 3)
  .map(d => {return {temperature: Number(d['Temperature(Celsius)(avg)']), date: d.Date, category: '平均气温'}});
console.log(dataset);

如上面代码所示，我们通过 fetch 读取 csv 的数据。CSV 文件格式是用逗号和回车分隔的文本，所以我们用.text() 读取内容。然后我们使用 d3 的 csvParse 方法，将数据解析成 JSON 数组。最后，我们再通过数组的 filter 和 map，将我们感兴趣的数据取出来。这里，我们截取了 1 月到 3 月的平均气温数据。

取到了想要的数据，接下来我们就可以将它展示出来了，这一步我们可以使用数据驱动框架。在预习篇我们讲过，数据驱动框架是一种特殊的库，它们更专注于处理数据的组织形式，将数据呈现交给更底层的图形系统（DOM、SVG、Canvas）或通用图形库（SpriteJS、ThreeJS）去完成。

但是，为了方便你理解，这里我就不使用数据驱动框架了，而是直接采用一个图表库QCharts，它是一个基于 SpriteJS 设计的图表库。与数据驱动框架相比，图表库虽然减少了灵活性，但是使用上更加方便，通过一些简单的配置，我们就可以完成图表的渲染。

用来展示平均气温最常见的图表就是折线图，展示折线图的过程可以简单分为 4 步：第一步是创建图表（Chart）并传入数据；第二步是创建图形（Visual)，这里我们创建的是折线图，所以使用 Line 对象；第三步是创建横、纵两个坐标轴（Axis）、提示（ToolTip）和一个图例（Legend）；最后一步是将图形、坐标轴、提示和图例都添加到图表上。具体的代码如下：

const { Chart, Line, Legend, Tooltip, Axis } = qcharts;
const chart = new Chart({
  container: '#app'
});
let clientRect={bottom:50};
chart.source(dataset, {
  row: 'category',
  value: 'temperature',
  text: 'date'
});

const line = new Line({clientRect});
const axisBottom = new Axis({clientRect}).style('grid', false);
axisBottom.attr('formatter', d => '');
const toolTip = new Tooltip({
  title: arr => {
    return arr.category
  }
});
const legend = new Legend();
const axisLeft = new Axis({ orient: 'left',clientRect }).style('axis', false).style('scale', false);

chart.append([line, axisBottom, axisLeft, toolTip, legend]);

这样，我们就将图表渲染到画布上了。

处理多元变量

刚才我们已经成功将平均气温这个变量用折线图展示出来了，但在很多数据可视化场景里，我们不只会关心一个变量，还会关注多个变量，比如，我们需要同时关注温度和湿度数据。那怎么才能把多个变量绘制在同一张图表上呢？换句话说，同一张图表怎么展示多元变量呢？

在一张图表上绘制多元变量

最简单的方式是直接在图表上同时绘制多个变量，每个变量对应一个图形，这样一张图表上就同时显示多个图形。

我们直接以刚才的代码为例，现在，我们修改例子中的代码，直接添加平均湿度数据，代码如下：

const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData).filter(d => new Date(d.Date).getMonth() < 3);
const dataset1 = data
  .map(d => {
    return {
      value: Number(d['Temperature(Celsius)(avg)']),
      date: d.Date,
      category: '平均气温'}
    });
const dataset2 = data
  .map(d => {
      return {
        value: Number(d['Humidity(%)(avg)']),
        date: d.Date,
        category: '平均湿度'}
      });

然后，我们修改图表的数据，将温度（dataset1）和湿度（dataset2）数据都传入图表，代码如下：

chart.source([...dataset1, ...dataset2], {
  row: 'category',
  value: 'value',
  text: 'date'
});

这样，我们就得到了同时显示温度和湿度数据的折线图。

用散点图分析变量的相关性

不过，你应该也发现了，把温度和深度同时绘制到一张折线图之后，我们很难直观地看出温度与湿度的相关性。所以，如果我们希望了解 2014 年全年，北京市温度和湿度之间的关联性，我们还得用另外的方式。那都有哪些方式呢？

一般来说，要分析两个变量的相关性，我们可以使用散点图，散点图有两个坐标轴，其中一个坐标轴表示变量 A，另一个坐标轴表示变量 B。这里，我们将平均温度、相对湿度数据获取出来，然后用 QCharts 的散点图（Scatter）来渲染。具体的代码和示意图如下：

const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData);
console.log(data);
const dataset = data
  .map(d => {
    return {
      temperature: Number(d['Temperature(Celsius)(avg)']),
      humdity: Number(d['Humidity(%)(avg)']),
      category: '平均气温与湿度'}
    });

const { Chart, Scatter, Legend, Tooltip, Axis } = qcharts;
const chart = new Chart({
  container: '#app'
});
let clientRect={bottom:50};
chart.source(dataset, {
  row: 'category',
  value: 'temperature',
  text: 'humdity'
});

const scatter = new Scatter({
  clientRect,
  showGuideLine: true,
});
const toolTip = new Tooltip({
  title: (data) => '温度与湿度：',
  formatter: (data) => {
    return `温度：${data.temperature}C  湿度：${data.humdity}% `
  }
});
const legend = new Legend();
const axisLeft = new Axis({ orient: 'left',clientRect }).style('axis', false).style('scale', false);
const axisBottom = new Axis();

chart.append([scatter, axisBottom, axisLeft, toolTip, legend]);

从这个图表我们可以看出，平均温度和相对湿度并没有相关性，所以点的空间分布比较随机。事实上也是如此，气温和绝对湿度有关，但相对湿度因为已经考虑过了温度因素，所以就和气温没有相关性了。

那你可能会有疑问，相关的图形长什么样呢？我们可以用另外两个变量，比如露点和平均温度，来试试看能不能画出相关的散点图。

我们先来说说什么是露点。在空气中水汽含量不变， 并且气压一定的情况下， 空气能够冷却达到饱和时的温度就叫做露点温度， 简称露点， 它的单位与气温相同。

从定义里我们知道，露点和温度与湿度都有相关性。接下来，我们来看一下露点和温度的相关性在散点图中是怎么体现的。很简单，我们只要修改一下上面代码里的数据，把平均湿度换成平均露点温度就行了。

const dataset = data
  .map(d => {
    return {
      temperature: Number(d['Temperature(Celsius)(avg)']),
      tdp: Number(d['Dew Point(Celsius)(avg)']),
      category: '平均气温与露点'}
    });

这样，我们最终呈现出来的散点图具有典型的数据正相关性，也就是说图形的点更集中在对角线附近的区域。

我们还可以把湿度数据也加上。

const dataset = data
  .map(d => {
    return {
      value: Number(d['Temperature(Celsius)(avg)']),
      tdp: Number(d['Dew Point(Celsius)(avg)']),
      category: '平均气温与露点'}
    });
const dataset2 = data
  .map(d => {
    return {
      value: Number(d['Humidity(%)(avg)']),
      tdp: Number(d['Dew Point(Celsius)(avg)']),
      category: '平均湿度与露点'}
    });

...
chart.source([...dataset, ...dataset2], {
  row: 'category',
  value: 'value',
  text: 'tdp'
});

我们发现，平均湿度和露点也是成正相关的，不过露点与温度的相关性更强，因为散点更集中一些。

为了再强化理解，我们还可以看一组强相关的数据，比如平均温度和最低温度，你会发现，图上的散点基本上就在对角线上。

总的来说，两个数据的散点越集中在对角线，说明这两个数据的相关性越强，当然这么说还不够严谨只是方便我们记忆而已。这里，我找到了一张散点图和相关性之间关系的总结图，你可以多了解一下。

散点图的扩展

通过前面的例子，我们可以看到，用散点图可以分析出数据的二元变量之间的相关性，这对数据可视化场景的信息处理非常有用。不过，散点图也有明显的局限性，那就是它的维度只有二维，所以它一般只能处理二元变量，超过二维的多元变量的相关性，它处理起来就有些力不从心了。

不过，我们还不想放弃散点图在相关性上的优异表现。所以在处理高维度数据时，我们可以对散点图进行扩展，比如引入颜色、透明度、大小等信息来表示额外的数据维度，这样就可以处理多维数据了。

举个例子，我在下面给出了一张根据加州房产数据集制作的散点图。其中，点的大小代表街区人口数量、透明度代表人口密度，而颜色代表房价高低，并且加上经纬度代表点的位置。这个散点图一共表示了五维的变量（经度、纬度、人口总数、人口密度、房价高低），将它们都呈现在了一张图上，这在一定程度上表达了这些变量的相关信息。

图片来源：知乎

这里，我带你做个简单的分析。从这张图上，我们很容易就可以得出两个结论，第一个是，房价比较高的区域集中于两个中心，并且都靠近海湾。第二个是房价高的地方对应的人口密集度也高。

这就是用散点图处理多维数据的方法了。

其他图表形式

事实上，处理多维信息，我们还可以用其他的图表展现形式，比如用晴雨表来表示数据变化的趋势就比较合适。北大可视化实验室在疫情期间就制作了一张疫情数据晴雨表，你能明显看出每个省份每天的疫情变化。如下所示：

图片来源：mp.weixin.qq.com

再比如，还有平行坐标图。平行坐标图也有横纵两个坐标轴，并且把要进行对比的五个不同参数都放在了水平方向的坐标上。在下面的示意图中，绘制了所有 4 缸、6 缸或 8 缸汽车在五个不同参数（变量）上的对比。

左为平行坐标图，右为热力图

此外，我们还可以用热力图、三维直方图、三维气泡图等等其他的可视化形式来展现多维度的信息。

左为三维直方图，右为三维气泡图

总之，这些数据展现形式的基本实现原理，我们都在前面的视觉篇中讲过了。在掌握了视觉基础知识之后，我们就可以用自己想要的呈现形式，自由发挥，设计出各种直观的、形式有趣的图表了。

要点总结

这一节课，我们主要讨论数据到图表的展现以及如何处理多元变量。

在数据到图表的展现中，我们首先用 d3.js 把原始数据从 csv 中读取出来，然后选择我们需要的数据，用简单的图表库，比如，使用 QCharts 图表库进行渲染。渲染过程可以分为 4 步，分别是：创建图表对象 Chart 并传入数据，创建图形 Visual，创建坐标轴、提示和图例，把图形、坐标轴、提示和图例添加到图表对象中完成渲染。

在处理多元变量的时候，我们可以用散点图表示变量的相关性。对于超过二维的数据，我们可以扩展散点图，调整颜色、大小、透明度等等手段表达额外的信息。除了散点图之外，我们还可以用晴雨表、平行坐标图、热力图、三维直方图、气泡图等等图表，来表示多维数据的相关性。

到这里，我们用两节课的时间讲完了可视化的数据处理的基础部分。这部分内容如果再深入下去，就触达了数据分析的领域了，这也是一个和可视化密切相关的学科。那我也说过，可视化的重点，一是数据、二是视觉，视觉往深入研究，就进入渲染引擎、游戏等等领域，数据往深入研究，就进入数据分析的领域。所以，在可视化的入门或者说是基础阶段，掌握我现在讲的这些基础知识就够了。当然，如果你想深入研究也是好事，你可以参考我在课后给出的文章好好阅读一下。

小试牛刀

我在 GitHub 代码仓库里放了两份数据，一份是我们今天讲课用到的，另一份是2013 到 2018 年的全国各地空气质量数据。你能把 2014 年北京每日 PM2.5 的数据用折线图表示出来吗？你还能结合这两份数据，用散点图分析平均气温和 PM2.5 指数的相关性吗？

你可以模仿我北大可视化实验室的疫情晴雨表，实现一个 2018 年全国各城市空气质量晴雨表吗？

欢迎在留言区和我讨论，分享你的答案和思考，也欢迎你把这节课分享给你的朋友，我们下节课见！

源码

课程中完整示例代码见 GitHub 仓库

推荐阅读

[1] 从 1 维到 6 维 - 多维数据可视化策略

[2] QCharts

35| 设计（一）：如何让可视化设计更加清晰？

你好，我是月影。

在实际的可视化项目中，我们经常会遇到一种情况：用户期望所有的可视化图表都是简单明了的。实际上，这是不现实的。

因为我们拿到原始数据之后，第一步是分析数据，也就是从各种不同的角度尝试去观察数据，确定我们希望用户了解的信息。这些信息如果是简单清晰的，那么可视化结果就是简单直观的。如果用户想要了解的数据规律本身就很复杂，那么可视化图表所能做的事情，也只能是尽可能清晰地展现用户关注的重要信息，屏蔽干扰信息，来降低用户理解数据的难度。

因此，我们要明白，在任何时候，制作可视化图表都是为了帮助人们理解抽象的数据，不管这些数据多复杂，都要尽可能让读者快速理解。如何才能做到这一点呢？简单来说，就是你要学会了解人们是怎样看数据的，然后将数据呈现得符合人们的思维和理解习惯。

接下来，我们就通过几个例子来学习一下，都有哪些方法可以轻松地把人们的注意力集中在数据信息上。

分清信息主次，建立视觉层次

我们可以先想这么一个问题：第一次看图表的时候，你都会注意哪些信息？如果是我的话，我总会试图在图表上找到什么有趣的东西。实际上，在看任何东西的时候，我们的眼睛总是倾向于识别那些引人注目的东西，比如，明亮的颜色，较大的物体，有特点的符号等等。因此，我们做可视化的时候，应当用醒目的颜色突出显示数据，把被淡化的其他视觉元素当作背景。其实，这就是我们今天要讲的第一个方法，建立视觉层次。

我们以上一节课平均温度与露点的散点图为例，来说说这具体是怎么做。

我们知道，一个可视化图表应该包括图形、图例、提示信息、坐标轴等等元素。其中，图形是最重要的元素，所以它一般会在图表最核心的区域呈现。上图中，我们就使用了比较鲜明的蓝色来突出图形。至于左侧和下方的坐标轴，我们就用比较淡的灰黑色来显示。背景中的辅助线存在感最弱，因为它们是用来辅助用户更认真地阅读图表、理解数值的，不是主要元素，所以我们会用非常淡的颜色把它们显示在背景里。这些元素就构成了一个有鲜明视觉层次感的图表。

不过，就这个图表而言，我们还可以把它做得更好。因为，我们实际上希望表达给用户的信息还包含了平均气温与露点的正相关性，如果用户对这个数学关系比较敏感，完全可以通过散点分布了解到它们的正相关性，但是对其他不敏感的用户来说，我们可以添加曲线图来引导他们。接下来，我们一起来看具体的做法，从中你就可以体会到建立视觉层次的思路了。

第一步，我们处理一下数值，将数据按照气温高低排序，然后对相同温度的数据进行分组，最后将相同温度下的平均露点温度计算出来。

// 露点排序
let dataset2 = [...dataset].sort((a, b) => a.tdp - b.tdp);

// 对相同露点的温度进行分组
dataset2 = dataset2.reduce((a, b) => {
  let curr = a[a.length - 1]
  if (curr && curr.tdp === b.tdp) {
    curr.temperature.push(b.temperature)
  } else {
    a.push({
      temperature: [b.temperature],
      tdp: b.tdp
    })
  }
  return a
}, []);

// 最后将露点平均温度计算出来
dataset2 = dataset2.map(d => {
  d.category = '露点平均气温'
  d.temperature = Math.round(d.temperature.reduce((a, b) => a + b) / d.temperature.length)
  return d
})

在计算好数据之后，我们将散点和曲线两个数组都传给图表对象。

const chart = new Chart({
  container: '#app'
});
let clientRect={bottom:50};
chart.source([...dataset, ...dataset2], {
  row: 'category',
  value: 'temperature',
  text: 'tdp'
});

最后，我们就能分别用散点和曲线图来呈现数据了

const ds = chart.dataset;
const d1 = ds.selectRows("平均气温与露点");
const d2 = ds.selectRows('露点平均气温');

// 散点图
const scatter = new Scatter({
  clientRect,
  showGuideLine: true,
}).source(d1);

// 曲线图
const line = new Line().source(d2);
line.style('line', function(attrs, data, i) {
  return { smooth: true, lineWidth: 3, strokeColor: '#0a0' };
});
line.style('point', function(attrs) {
  return { display: 'none' };
});

这个图表分为三个层次：第一层，我们用曲线描绘气温与平均露点的关系，随着气温升高，平均露点温度也会升高，二者的总体趋势保持一致。第二层，就是我们保留的散点图，我们可以通过它看出具体某一次记录的温度和露点数据，还可以从分布规律看出相关性的强度，因此它能够表达的信息比曲线图还要多一些。第三层，我们使用了坐标轴和辅助线作为背景。总之，像这样层次分明的图表，非常有助于我们快速理解图表上的信息。

选择合适图表，直观表达信息

建立视觉层次是第一种集中注意力的方法，第二种方法其实和它有点类似，就是用合适的图表更加直观地去表达信息。

还记得我们在 32 节课中讲的公园游客散点图吗？我们将男游客和女游客分别标记之后，可以看到不同区域在某个时刻的游客性别分布，比如下图就是公园 12 点游客分布情况。

公园12点游客散点图

不过，散点图虽然能够一眼看出不同性别游客在四个区域的大致分布，但不够直观。如果我们要更精细地分析的话，还是应该对数据呈现方式进行改进。

在表达某个单组变量的分布状况的时候，使用饼图是一个比较直观的方式。

比如，我们可以用一组饼图来表示中午 12 点公园游客在四个区域的性别分布，让它对应上面那张散点图。

第一步是处理数据。我们就以给 12 点的游客分类为例。我们根据游客所在的区域和性别来统计人数，对应到下面的代码，就是 countPeople 函数中实现的逻辑。

function count(d, dataset) {
  let place;
  if(d.x < 300 && d.y < 300) {
    place = 'square';
  } else if (d.x >= 300 && d.y < 300) {
    place = 'sections';
  } else if (d.x >= 300 && d.y >= 300) {
    place = 'garden';
  } else {
    place = 'playground';
  }
  dataset[place] = dataset[place] || [
    {
      gender: '男游客',
      people: 0,
    },
    {
      gender: '女游客',
      people: 0,
    }
  ];
  if(d.gender === 'f') {
    dataset[place][0].people++;
  } else {
    dataset[place][1].people++;
  }
  return dataset;
}

function groupData(data) {
  const dataset = {};

  for(let i = 0; i < data.length; i++) {
    const d = data[i];

    if(d.time === 12) {
      const p = count(d, dataset);
    }
  }
  return dataset;
}

const dataset = groupData(data);

经过这么分类之后，现在的数据集看起来就是下面这样：

第二步，我们用 qchars 来创建四个饼图。在图中，我们用橙色表示女游客，绿色表示男游客。这样，我们就能明显看出四个区域中男、女游客的分布情况了。具体的代码如下：

const { Chart, Pie, Legend, Tooltip, theme} = qcharts;
theme.set({
  colors: ['#71dac7', '#d57a77'],
});

Object.entries(dataset).forEach(([key, dataset]) => {
  const chart = new Chart({
    container: `#${key}`
  });
  chart.source(dataset, {
    row: 'gender',
    value: 'people',
    text: 'gender'
  });
  const pie = new Pie({
    radius: 0.7,
    animation: {
      duration: 700,
      easing: 'bounceOut'
    }
  });
  const legend = new Legend({ orient: 'vertical', align: ['right', 'center'] });
  const toolTip = new Tooltip();
  chart.append([pie, legend, toolTip]);
});

虽然饼图表示的结果非常简单和直观，但它也有缺点，就是一张饼图实际上能表示的信息很少。一个饼图一次只能表示一组单维度数据，而性别又只有男和女两种，所以我们为了表示四个区域，不得不用四张饼图，这会非常麻烦。

那我们可以尝试用一张图表来表示更多维度的信息吗？这当然可以。我们可以尝试把前面的四张饼图合并成一张嵌套饼图，它由两个饼状图组成，中间小的饼状图是女性在四个区域的分布情况，大的饼图是男性在四个区域的分布情况。

它的具体实现方法是，首先，我们在前面 groupData 的基础上对数据进行进一步处理，将数据对象扁平化，然后添加 place 属性。

const dataset = [];
Object.entries(groupData(data)).forEach(([place, d]) => {
  d[0].place = `${place}: 男`;
  d[1].place = `${place}: 女`;
  dataset.push(...d);
});

这样处理数据之后，新的数据结构如下：

然后，我们可以在这个数据结构的基础上绘制两个饼图，代码如下：

const { Chart, Pie, Legend, Tooltip, theme} = qcharts;
  const chart = new Chart({
    container: `#container`
  });

chart.source(dataset, {
  row: 'place',
  value: 'people'
});
const ds = chart.dataset;

const pie = new Pie({
  radius: 0.4,
  pos: [0, 0]
}).source(ds.selectRows(dataset.filter(d => d.gender === '女游客').map(d => d.place)));

const pie2 = new Pie({
  innerRadius: 0.5,
  radius: 0.7
}).source(ds.selectRows(dataset.filter(d => d.gender === '男游客').map(d => d.place)));

const legend = new Legend({ orient: 'vertical', align: ['right', 'center'] });

chart.append([pie2, pie, legend]);

这样我们就用一张图表直观地显示了，中午 12 点，公园内四个区域中男女游客的分布情况了。不过实际上，这张图表和前面的四个饼图表达的信息不同，那四张饼图表达的是某个区域男女游客分布的情况，而这张图则是表达男女游客分别在四个区域的分布情况，它们正好是互补的。

那么有没有办法将游客性别和游客区域的分布情况融合在一起表达呢？这也是可以的。我们可以用堆叠的直方图，或者更加美观的南丁格尔玫瑰图来表达。这里，我选择用南丁格尔玫瑰图。

南丁格尔玫瑰图是一种圆形的直方图，用半径来表示数量和数据之间的区别。在这一张图上我们可以看出，四个区域的总人数分布，以及每个区域男女游客数量分布。

与上面的嵌套饼图一样，我们也要先把数据扁平化，不过这里要稍微修改了一下 place 属性。因为我们要将数据堆叠，所以让男女游客数据里，在同一个区域的游客统计的 place 属性保持一致就行了。

const dataset = [];
Object.entries(groupData(data)).forEach(([place, d]) => {
  d[0].place = place;
  d[1].place = place;
  dataset.push(...d);
});

明白了原理，我们就可以绘制出南丁格尔玫瑰图了。

const { Chart, PolarBar, Legend, Tooltip, theme} = qcharts;
  const chart = new Chart({
    container: `#container`
  });

theme.set({
  colors: ['#71dac7', '#d57a77'],
});

chart.source(dataset, {
  row: 'gender',
  value: 'people',
  text: 'place',
});
const bar = new PolarBar({
  stack: true,
  radius: 0.8,
  groupPadAngle: 15,
}).style("pillar", {
  strokeColor: "#FFF",
  lineWidth: 1,
});
const tooltip = new Tooltip();
const legend = new Legend({ orient: 'vertical', align: ['right', 'center'] });

chart.append([bar, tooltip, legend]);

具体的方法讲完了，我们做个总结。使用南丁格尔玫瑰图，我们能把人群在公园区域的分布和性别分布规律显示在一张图上，让更多的信息呈现在一张图表上。这既能节省空间，也便于人们高效率地获取更多信息。但是，太多的信息聚集也会显著增加图表复杂度，减少图表的直观程度。就像这张南丁格尔图一样，它虽然简单，但直观性仍然不如之前用四个饼图和一个嵌套饼图的表达形式。所以在我们实际可视化项目中，需要根据实际情况选择合适的解决方案，大部分情况下，我们需要在直观性和信息聚集程度上做一个取舍。

改变图形属性，强化数据差异

除了直观表达信息外，我们还可以采用一些其他的手段，比如，改变颜色、大小、形状等等，以此来强化数据之间的差异，这也是增强可视化图表中信息表达的一种手段。

这次我们就不具体实现了，直接来看一个成熟的例子。比如说，北大可视化实验室设计的全国新冠病毒肺炎疫情晴雨表，就是使用颜色和方块大小，将增量数据很直观地表达出来，从而宏观地呈现出疫情的发展态势，这对于揭示疫情拐点来说非常有帮助。

颜色和面积、折线图的方向、直方图的高度差，这些方式都能比较鲜明地体现出数据之间差异。除此之外，我们在绘制图表的时候，还可以使用背景网格线，来辅助用户观察数据间的差异，发现数据之间的变化规律。

说到强调数据差异，我还想给你说一种比较完美的图表，它就是股市中常用的蜡烛图，又叫做 K 线图。

我要先说明一点，我不鼓励任何人进行投机性的炒股。但不得不说，股市里的 K 线图是一个非常成功的可视化案例，这个图表用颜色来强化数据的增减，还包含了许多其他有用的信息，让想要了解股市市场的人，能够从中分析出商品或者股票的价格走势，再做出相应的决策。

总之，从这些优秀的案例中我们知道，在可视化实现中，我们应该重视数据之间的比较，用一些图形属性来强调数据的差异，这对加强图表的表现力非常有效。

要点总结

这节课，我们学习了让可视化设计更加清晰的三种方法。首先，我们应该学会建立视觉层次，信息有主次之分，我们要把重要的信息突出显示，减少次要信息对比，以及干扰信息的存在感。

其次，我们要学会用合适的图表来直观地表达信息，比如说，一般情况下，我们会用饼图来描述变量值的分布，用嵌套饼图来展现更多信息，用南丁格尔玫瑰图聚合多维度信息。

最后，我们还应该重视数据之间的比较。大部分情况下，我们会使用一些图形属性，比如更改图形颜色、形状等等，来强调数据之间的差异，这样能够增强图表的表现力。

总的来说，可视化设计的许多方法，其实都是源于实践经验的积累。想要学好，真的没有捷径可走，今天我讲的这三种方法也都是特例。学会它们之后，你还是要多练习，争取做到举一反三，这样才能在可视化设计这条路上走得更远。

小试牛刀

你可以利用我放在GitHub仓库里的天气和空气质量数据，设计出可视化案例，展现出和散点图不一样的内容吗？比如说，你可以让图表展现不同月份下，晴天和雾霾天的分布率。

当然，如果你有更好的创意，我非常鼓励你把它们分享出来，期待在留言区看到你的作品。今天就讲到这里，我们下节课见！

源码

示例代码详见 GitHub 仓库

36 | 设计（二）：如何理解可视化设计原则？

你好，我是月影。

工作中，很多新人在进行可视化设计的时候，往往因为想要表达的内容太多，或者不知道该怎么突出重点信息，而设计不出好看的图表。所以他们总会来问我，有没有可以用来参考的设计原则啊？

今天，我就把我从实战中总结出的一些经验，整理出的基本设计原则分享给你，主要有四个，分别是简单清晰原则、视觉一致性原则、信息聚焦原则，以及高可访问性原则。接下来，我们就一一来说。

简单清晰原则

当我们刚开始进行可视化设计的时候总会认为，只有用尽可能多的数据，才能做出十分酷炫的效果。但实际上，可视化真正的价值并不是这些看上去酷炫的效果，而是准确地表达信息内容。一味地堆砌数据，只会让真正有用的信息淹没在干扰信息里，从而影响人们解读信息中真正有价值的内容。

因此，简单清晰原则是可视化设计中最重要的一条基本原则。下面，我们来看一个反面例子。

图片来源：cloud.tencent.com

这是国际茶叶委员会制作的全球茶叶消费排行榜图表，这张图表本身其实问题不是很大。不过，如果引用这张图表是为了说明喝茶最多的不是中国人，就有点太复杂了。我相信你第一眼甚至看不到中国的数据在哪，就更别说看懂这张图了。

另外，这张图底部的直方图表示消费，上方的连线和最上面的横条表示什么，也不够清晰。如果我们仔细研究，还是可以看出，最上方的横条表示茶叶产量，其中 33.7% 供应国内，中国人均只消费了 566 克茶叶。由此，我们可以得出的结论是：中国是产茶大国，但茶叶消费数量不是最多的，茶叶更多会出口给印度、越南、土耳其和欧洲的一些国家。

那这张该怎么优化呢？首先，我们要明确这张图到底要让我们知道什么。如果这张图想让我们知道中国产茶和茶叶销量的结论，那么我们可以把中国的部分用更加醒目的颜色标记出来。如果只需要研究茶叶消费，那么我们可以完全忽略上面关于生产的部分，直接用更简单的销量直方图展示图表结果。

其次，我们完全不需要列出这么多的国家，列出更少的国家可以让用户更快地抓住重点。所以我们直接用下面的简单的直方图表达，就可以表示我们要表达的内容了。

如果想把产茶的信息也包含进来，我们可以再增加一个饼图。

这两张图加起来已经足够明确地表示出我们想要表达的信息了，也比原始的图表更加简单直观。当然，你也可能觉得还是原始的图信息量大，显得更加有用，这一点就见仁见智了，可视化设计的好坏本来就没有定论，原则也只是参考。

视觉一致性原则

几乎在前面的所有实战案例里，我都强调过颜色的运用。在可视化中，颜色对于强化信息有着非常大的帮助。配色良好的图表，不仅看起来赏心悦目，也能帮助我们快速定位到想要关注的信息。

虽然严格上来说，配色是 UI 设计师该负责的事儿，但是作为可视化工程师，我们也应该了解一些基本的配色原则。一般来说，有两种比较常用的配色方案，分别是互补色方案和同色系方案。

第一种配色方案是，当想要突出数据之间的差异时，我们可以用互补色来增强对比效果。那么什么是互补色呢？你还记得我们在第 10 节课里学过的色彩表示吗？其中 HSV 颜色表示法是用色相（HUE）、饱和度（Saturation）和明度（Value）来表示色值的。所谓的互补色，就是指在饱和度和明度相同的情况下，色相值相差 180 度的一对颜色。因为互补色色相差距最大，所以它们并列时会产生强烈的视觉对比效果，这样能够起到强调差异的作用。

当然，我们实际进行数据对比的时候，并不会严格要求两个颜色是差异 180 度的互补色，而是会采用差异较大的，差值比较接近 180 度的两种颜色，这样也算是互补色。下面这张图，就使用了红、绿互补色的配色方案。

另外一种配色方案是采用同色系，利用不同深浅的同色系颜色来表示不同的数据。同色系方案的对比没有这么强烈，它从视觉上给人的感觉更柔和，而且色彩的一致也能够减少我们看图表时的视觉疲劳，从而让人保持注意力集中，帮助我们理解图表信息。下面这张图就采用同色系配色方案。

在具体项目中，使用互补色还是同色系方案不是绝对的，我们要看具体的应用场景：如果你想要突出数据项之间的差异，那么采用对比色方案；如果你想要让人长时间关注，尤其是一些复杂的大型图表，那么采用同色系方案就是更好的选择。

信息聚焦原则

在前面的课程中，我们已经知道，长度、高度、大小、形状、颜色、透明度等等都可以用来表示变量。这样，我们就能在一张图上表示多元变量，同时我们改变这些属性还可以让信息更加聚焦。

比如，下面这张示例图表就是一个非常好的例子。

这是一张天级预报的图表，图表上同时显示了温度、天气、风向、风速、浪高这些变量，每个变量都采用了不同的形式来展示，区分度很好，内容非常清晰也很聚焦。另外，图表上的除了用不同的 y 轴高度来表示风速之外，还采用红色、黄色、绿色这三种颜色标记了不同的风速等级，这就是用两种方式表示同一个元素。

一般来说，我们用一个图属性来表示一个变量，比如箭头方向表示风向，高度表示风速，但如果我们要强调某个变量的时候，我们也可以用超过一个属性来表示，比如用颜色和高度同时表示风速，这就起到了强调的作用。类似的示例还有下面这张图表。

在这张图表上，为了强调产品的生产量，我们用立方体的高度和颜色同时表示了这一个维度的变量。

总之，我们可以将相关的多元变量聚合在一张图表上，用来更聚焦地表达多元信息。不过，这么做的时候，我们要确定我们需要的信息真的包括了这些多元变量，并且它们彼此是有相关性的，否则我们还是应该考虑将它们拆分或者过滤掉无用的信息，这样才不违背前面的简单清晰原则。

高可访问性原则

如果你是一名前端工程师，我相信你应该听过网页设计的可访问性，或者说无障碍原则。因为可视化本身和前端密不可分，所以在可视化设计中，我们也同样需要考虑设计高可访问性。

可视化的无障碍设计，主要体现在色彩系统上。要知道，我们的用户可能包含视觉障碍人群，而且我们的图表可能呈现在不同的设备上，从高端的 4K 显示屏、普通液晶显示器到投影仪、彩色打印机或者黑白打印机。

因此，即使我们设计的颜色在我们看来已经足够有差异性了，也可能在一些低色彩分辨率的设备上表现得不那么友好，甚至会给视觉障碍人士带来困扰。

这就要求我们在设计上要尽量避免对视觉障碍人士不太友好的配色，比如，用黄色和黄绿色来区分内容。在使用同色系配色方案的时候，我们也要注意色彩在明亮度和饱和度上要有足够的差异，以便于在黑白打印机等设备上打印出来的图表也有足够的区分度。

还有一种情况是，如果重要的变量要依赖于颜色，而你又不能保证色彩的可访问性时，采用前面的方法，用其他属性和颜色一起来表示目标变量，这样既能够起到强调作用，也预防了单纯使用颜色对视觉障碍人士带来困扰。

除此之外，我们还可以用一些色彩检查工具来辅助配色。在 Photoshop 中，选择视图 -> 校样设置 -> 红色盲型 / 绿色盲型之后，就能看到我们设计的图表颜色在视觉障碍人群眼中的效果了。如果你不怎么用 Photoshop，还可以访问 color-blindness.com的在线服务，通过上传图片来检查你的配色。

除了颜色之外，文字提示信息也需要考虑可访问性。首先，提示字体的大小要适中，并且足够清晰。其次，要对于老年人和视力不好的人群提供缩放字体的功能，这样能够在很大程度上改善可访问性。

虽然网页本身提供了文字内容缩放的功能，但是图表库可能没有考虑文字缩放后的布局呈现。比如 Echarts 的这个图表，如果我们放大浏览器上的文字，它们就会叠在一起，完全不可阅读，这就是一个可访问性不太好的反面案例。

总之，图表设计的目的是给人阅读的，我们只有在可访问性上下功夫，才能让更多的人读懂图表，更好地发挥出它的价值。

要点总结

这一节课，我们讨论了可视化设计的四个基本原则。

首先是简单清晰原则，要明确我们的图表要表达的信息，只呈现给用户希望让用户看到的内容，不要增加太多干扰项，保持图表的简洁清晰，有助于读者快速获得有用的信息。

其次是视觉一致性原则，在配色的时候，配色原则要统一。我们可以采用补色方案，也可以采用同色系方案，它们各有利弊。补色方案可以增强对比，在我们要强调数据差异性的时候，一般采用补色方案，而同色系方案在视觉上更柔和，不容易疲劳，在比较复杂的或者需要用户长时间关注的图表里，采用同色系方案是一种更好的选择。

第三是信息聚焦原则，我们可以用多个属性将多元信息聚集在一个图表里，让读者能够更高效率地获取信息。不过我们要确保信息确实是有关联的，需要且易于读者理解的，否则的话，我还是建议将它们拆分到不同图表，这样才不违背简单清晰的原则。

最后，图表设计要考虑可访问性，也就是无障碍原则。在配色上要考虑到视觉障碍人士，尽量不要让他们产生困扰，另外如果网页能支持文字的缩放功能，就会对老年人和视力不佳的人群更友好，从而让更多人读懂图表，更好地发挥出它的价值。

小试牛刀

在前面几节课中，我留下了一些练习题，不知道你都完成了吗？如果你完成了，你可以回顾一下自己实现的可视化图表，看看是否符合或者违反了我们今天讲的四个原则。如果违反了某些原则，你可以尝试去改进，再把改进前、后的对比结果分享到留言区。

如果你已经完成得非常好了，没什么可改进了，也可以用我在 GitHub 仓库提供的两份数据，来试着实现符合这四个原则的高质量可视化图表。

这四个简单又实用的设计原则，你学会了吗？如果你的朋友正在为设计图表而困扰，欢迎你把这节课分享给他，说不定，问题就解决了呢？好了，期待在留言区看到你的作品。今天就讲到这里，我们下节课见！

推荐阅读

Echarts 图表库是一个不错的开源图表库，其中有许多经典的例子，我建议你认真看一下，想一想它们是否符合或违反了前面总结的四个原则，并试着学习、理解和使用这些图表。

37 | 实战（一）：如何使用图表库绘制常用数据图表？

你好，我是月影。

图表是我们在可视化中展示数据常用的方式之一，常见的有柱状图、折线图、饼图等等，我们之前也都一一实现过。如果我让你总结一下图表的实现方法，你能说出来吗？总结不出来也没关系，这节课，我们就一起梳理一下实际项目中常用的制图方法。

实现图表有两种方式，一是使用现成的图表库，二是使用数据驱动框架，前者胜在简单易用，后者则更加灵活。所以，我们会用两节课的时间分别来讲，使用图表库和使用数据驱动框架的制图思路。

因为使用图表库更加简单，所以这一节课我们先来讲怎么使用它实现图表。另外，这节课的实践性比较强，我建议你跟着我的讲解一块儿动手去写，这样能更快地理解课程的内容。

课前准备

说了这么多，我们今天到底会用哪些图表库呢？我们主要会使用我们比较熟悉的 SpriteJS 和 QCharts 来绘制图表。其他的图表库，例如更常用的ECharts，它在图表实现上的原理和用法和我们今天讲的基本相同，所以学完了今天的内容，你完全可以根据它的教程文档去自学。

好了，确定了要使用的工具之后，我们就要配置和加载 SpriteJS 和 QCharts。具体怎么做呢？

最简单的方式是，我们直接通过 CDN，用 script 标签来加载 SpriteJS 和 QCharts 打包好的文件。

<script src="https://unpkg.com/spritejs/dist/spritejs.min.js"></script>
<script src="https://unpkg.com/@qcharts/core@1.0.25/dist/index.js"></script>

如果是完整的工程项目的话，你也可以使用 webpack 来打包和加载模块。这里有一个Quick Start，你可以 fork 这个项目，按照其中的配置项来设置。当加载完成之后，我们就可以开始绘制基础的图表了。

QCharts 图表的基本用法

QCharts 图表由图表（Chart）对象及其子元素构成。其中图表对象的子元素包含图形（Visual）和其他插件（Plugin)。图形是必选元素，其他的插件都是可选元素。

图表在构建的时候，需要传入一个 DOM 元素，这个元素可以是页面上任意一个块级元素，QCharts 用这个元素作为容器来创建 Canvas 画布，并其还会根据容器的大小来设置 Canvas 画布的大小。默认情况下，图表会根据画布大小来自动适配。

接下来，我们看一下具体的操作。首先，我们创建一个图表对象，设置它的容器设置成一个 id 为 app 的元素。

const { Chart, Line } = qcharts;
const chart = new Chart({
  container: '#app'
});

创建了这个容器之后，我们就可以为它绑定数据，假设我们绑定一份销售数据。

const data = [
  { date: '05-01', category: '图例一', sales: 15.2 },
  { date: '05-02', category: '图例一', sales: 39.2 },
  { date: '05-03', category: '图例一', sales: 31.2 },
  { date: '05-04', category: '图例一', sales: 65.2 },
  { date: '05-05', category: '图例一', sales: 55.2 },
  { date: '05-06', category: '图例一', sales: 75.2 },
  { date: '05-07', category: '图例一', sales: 95.2 },
  { date: '05-08', category: '图例一', sales: 100 }
];

chart.source(data, {
  row: 'category',
  value: 'sales',
  text: 'date'
});

如上面代码所示，我们将数据内容与图表对象通过 chart.source 方法绑定起来。绑定数据的时候，我们可以指定数据的行（列）、数值和文本，这些设置会被图形（Visual）和其他插件使用。这里，我们将行设为 category 这个字段，数值设为 sales 字段，文本设为 data 字段。

设置之后，图表并没有马上显示出来。这是因为，我们还没有给图表指定图形。QCharts 支持多种图形对象，比如 Line、Area、Bar 等等。假设我们选择了 Line 对象，那我们只需要创建它，然后将它添加到 chart 对象的子元素中就可以了。

const line = new Line();
chart.append([line]);

这样，我们就让图形显示出来了，效果如下：

了解了 QCharts 图表的基本用法之后，我们一起进入实践环节吧。

QCharts 绘制折线图、面积图、柱状图和饼图的方法

我们先来讲讲折线图、面积图、柱状图和饼图的实现方法，因为之前已经实现过很多次了，所以理解起来比较容易。

首先是折线图，它是可视化中最常用的图表之一。刚才我们用 Line 图形绘制了一条折线，但它还不是完整的折线图。完整的折线图，除了有图形以外，还要有表示数据的坐标轴、提示信息和图例，这些都需要在 QCharts 中由插件来完成。

接下来，我们就继续给前面的代码添加元素。首先，我们给它增加两个坐标轴，分别是底部和左侧的。

我们通过 Axis 类来创建 axis 对象，默认的坐标轴是在底部不用修改，再通过.style 改变它的样式属性，比如我们将"grid"设置为 false，这样画布上不会显示纵向的网格线。

const axisBottom = new Axis().style("grid", false);

然后，我们同样创建一个 axis 对象，通过设置 orient: “left” 让它朝左，这样就成功创建了左侧的坐标轴。同样，我们也要把它样式中的"axis"属性设置为 false，这样画布上就不会显示坐标轴的实线了。

const axisLeft = new Axis({ orient: "left" })
      .style("axis", false);

最后，我们将两个坐标轴添加到 chart 对象的子元素中去，就可以将坐标轴显示出来。

添加完坐标轴之后，我们再添加图例（Legend）和提示（Tooltip）。最简单的方式，是我们直接创建两个对象，然后将它们添加到 charts 对象的子元素中。

const legend = new Legend();
const tooltip = new Tooltip();
chart.append([line, axisBottom, axisLeft, legend, tooltip]);

添加了图例和提示信息后的图表如下图所示：

接下来，我们再来说说怎么用 QCharts 绘制面积图和柱状图。

学会了折线图的绘制方法，我们就可以用相同的思路非常简单地绘制其他图形了，比如说，我们可以简单将 Line 对象换成 Area 或 Bar 对象，这样就可以用同样的数据绘制出面积图或柱状图。这是为什么呢？

将 Line 改成 Area 绘制出面积图

将 Line 改成 Area 绘制出柱状图

因为像折线图、面积图、柱状图这些表现形式不同的图表，它们能够接受同样格式的数据，只是想要侧重表达的信息不同而已。一般来说，折线图强调数据变化趋势，柱状图强调数据的量和差值，而面积图同时强调数据量和变化趋势。在实际项目中，我们要根据不同的需求选择不同的基本图形。

如果要强调整体和局部比例，我们还可以选择绘制饼图。与折线图、面积图、柱状图相比，饼图不用配置图例，因为图例会自动生成，而且饼图也不需要坐标轴，所以使用起来更加简单。

const data = [
  { date: '05-01', sales: 15.2 },
  { date: '05-02', sales: 39.2 },
  { date: '05-03', sales: 31.2 },
  { date: '05-04', sales: 65.2 },
  { date: '05-05', sales: 55.2 },
  { date: '05-06', sales: 75.2 },
  { date: '05-07', sales: 95.2 },
  { date: '05-08', sales: 100 }
];

chart.source(data, {
  row: 'date',
  value: 'sales',
  text: 'date'
});

const pie = new Pie();
const legend = new Legend();
const tooltip = new Tooltip();
chart.append([pie, legend, tooltip]);

上面的代码用同样的数据绘制了一个饼图。

QCharts 绘制雷达图、仪表盘和玉玦图、南丁格尔玫瑰图

讲完了这些常见的基础图表，我们再来看几个比较有趣的图表。

首先是雷达图，它一般用来绘制多组固定数量的数据，可以比较直观地显示出这组数据的特点。我们经常会在游戏中看见它的应用，比如，下面这张图表就显示了三国武将诸葛亮的各方面数据。

雷达图的实现代码如下：

const data = [
  { date: '体力', category: '诸葛亮', sales: 100 },
  { date: '武力', category: '诸葛亮', sales: 69 },
  { date: '智力', category: '诸葛亮', sales: 100 },
  { date: '统帅', category: '诸葛亮', sales: 95 },
  { date: '魅力', category: '诸葛亮', sales: 95 },
  { date: '忠诚', category: '诸葛亮', sales: 100 },
];

chart.source(data, {
  row: 'category',
  value: 'sales',
  text: 'date'
});

const radar = new Radar();
radar.style('section', (d) => ({ opacity: 0.3 }));

const legend = new Legend({ align: ['center', 'bottom'] });
const tooltip = new Tooltip();
chart.append([radar, legend, tooltip]);

除此之外，还有一些其他类型的图表，可以用来展示特殊的信息。比较典型的有仪表盘，它可以显示某个变量的进度。

仪表盘实现代码比较简单，如下所示：

const gauge = new Gauge({
  min: 0,
  max: 100,
  percent:60,
  lineWidth: 20,
  tickStep: 10
});

gauge.style('title', { fontSize: 36 });

chart.append(gauge);

如果我们要显示多个变量的进度，还可以使用玉玦图。

对应的代码也非常简单，如下所示：

const data = [
  {
    type: '政法干部',
    count: 6654
  },{
    type: '平安志愿者',
    count: 5341
  },{
    type: '人民调解员',
    count: 3546
  },{
    type: '心理咨询师',
    count: 4321
  },{
    type: '法律工作者',
    count: 3923
  },{
    type: '网格员',
    count: 5345
  }
].sort((a, b) => a.count - b.count);

chart.source(data, {
  row: 'type',
  text: 'type',
  value: 'count'
});

const radialBar = new RadialBar({
  min: 0,
  max: 10000,
  radius: 0.6,
  innerRadius: 0.1,
  lineWidth: 10
});

radialBar.style('arc', { lineCap: 'round' });

const legend = new Legend({
  orient: 'vertical',
  align: ['right', 'center'],
});

chart.append([radialBar, legend, new Tooltip()]);

最后是南丁格尔玫瑰图，它可以显示多维度的信息，比如下图就显示了男女游客在公园四个区域内的分布情况。南丁格尔玫瑰图的绘制思路和代码，我们在第 35 节课已经说过，这里就不再多说了。如果你还不熟悉，可以再去回顾一下。

QCharts 绘制图表组合

我们刚才讲的都是在图表上绘制单一变量，那要想在图表上聚合多元变量，比如在一张天气图表上同时展示降水量、气温，我们可以同时绘制多个图形来表示。

我们可以分两种情况来讨论，最简单的情况是用相同的图形来绘制不同的变量。这个时候，我们可以直接通过不同 category 来绘制多个图形。比如，下面的数据就是用两组 category 分别绘制了两条折线。

const data = [
  { date: "1月", catgory: "降水量", val: 15.2 },
  { date: "2月", catgory: "降水量", val: 19.2 },
  { date: "3月", catgory: "降水量", val: 11.2 },
  { date: "4月", catgory: "降水量", val: 45.2 },
  { date: "5月", catgory: "降水量", val: 55.2 },
  { date: "6月", catgory: "降水量", val: 75.2 },
  { date: "7月", catgory: "降水量", val: 95.2 },
  { date: "8月", catgory: "降水量", val: 135.2 },
  { date: "9月", catgory: "降水量", val: 162.2 },
  { date: "10月", catgory: "降水量", val: 32.2 },
  { date: "11月", catgory: "降水量", val: 32.2 },
  { date: "12月", catgory: "降水量", val: 15.2 },

  { date: "1月", catgory: "气温", val: 2.2 },
  { date: "2月", catgory: "气温", val: 3.2 },
  { date: "3月", catgory: "气温", val: 5.2 },
  { date: "4月", catgory: "气温", val: 6.2 },
  { date: "5月", catgory: "气温", val: 8.2 },
  { date: "6月", catgory: "气温", val: 15.2 },
  { date: "7月", catgory: "气温", val: 25.2 },
  { date: "8月", catgory: "气温", val: 23.2 },
  { date: "9月", catgory: "气温", val: 24.2 },
  { date: "10月", catgory: "气温", val: 16.2 },
  { date: "11月", catgory: "气温", val: 12.2 },
  { date: "12月", catgory: "气温", val: 6.6 },
];

chart.source(data, {
  row: "catgory",
  value: "val",
  text: "date",
});

const line = new Line({ axisGap: true });
...

如果我们想用不同类型的图形来展示多个变量，在 QCharts 中，我们只需要创建多个不同的图形对象，然后把它们都添加到 chart 对象的子元素中去就可以了。

const ds = chart.dataset;

const d1 = ds.selectRows("降水量");
const line = new Line({ axisGap: true })
  .source(d1)
  .style("point", { strokeColor: "#fff" });

const d2 = ds.selectRows("气温");
const bar = new Bar().source(d2);
bar.style("pillar", { fillColor: "#6CD3FF" });

如上面代码所示，我们先可以通过 chart.dataset 拿到通过.source 绑定给 chart 对象的数据集，然后，通过 selectRows 分别将降水量和气温数据过滤出来。接着，我们分别创建 Line 和 Bar 两个图形对象，再将降水量和气温数据分别绑定给它们。最后，我们将这两个对象同时添加到 chart 子元素列表里，就可以将两个不同类型的图形显示出来了，具体的效果如下：

要点总结

这节课，我们主要学习了 QCharts 图表库的使用。

QCharts 是基于 SpriteJS 的简单可视化图表库，我们通过它可以绘制各种类型的图表。一般来说，我们是先创建图表对象，然后绑定数据，接着添加图形对象以及其他的插件，包括图例和提示。通过将图形和插件以子元素的形式添加到图表对象上，就能把图表内容最终显示出来了。

我们可以很方便地根据数据特点和业务需要，用数据绘制折线图、面积图、柱状图、饼图、雷达图等图表，还可以绘制特殊的仪表盘和玉玦图。另外，如果要显示多维数据，我们也可以用稍微复杂一些的南丁格尔玫瑰图。

最后，我们还可以把多维度变量聚合在一个图表中来显示不同的图形组合。具体操作是，我们先筛选数据，然后创建不同类型的图形对象，最后将它们都添加到图表对象的子元素中。

总的来说，我们今天讲的其实都是 QCharts 图表库，最基础、最常用的方法。QCharts 还提供了众多其他类型的图表，以及灵活操作图表样式的 API。如果你有兴趣继续钻研，可以通过我课后给出的参考链接进一步学习。

小试牛刀

你学会了使用不同图表来表达不同数据了吗？你可以试着使用 GitHub 仓库里的北京市天气数据和空气质量数据，实现一个温度、湿度、风速、空气质量的聚合图表吗？如果用来展示温度、湿度、风速和空气质量的图形都不相同就更好了。

这些常用的制图方法你都学会了吗？下节课，我们会接着来学，怎么使用数据驱动框架来表达不同数据，挑战才刚刚开始呢，我们下节课再见！

源码

课程代码详见GitHub 仓库

推荐阅读

QCharts 官网

38 | 实战（二）：如何使用数据驱动框架绘制常用数据图表？

你好，我是月影。

上一节课，我们使用图表库实现了一些常用的可视化图表。使用图表库的好处是非常简单，基本上我们只需要准备好数据，然后根据图形需要的数据格式创建图形，再添加辅助插件，就可以将图表显示出来了。

图表库虽然使用上简单，但灵活性不高，对数据格式要求也很严格，我们必须按照各个图表的要求来准备数据。而且，图形和插件的可配置性，完全取决于图表库设计者开放的 API，给开发者的自由度很少。

今天，我们就来说说，使用数据驱动框架来实现图表的方式。这类框架以 D3.js 为代表，提供了数据处理能力，以及从数据转换成视图结构的通用 API，并且不限制用户处理视图的最终呈现。所以它的特点是更加灵活，不受图表类型对应 API 的制约。不过，因为图表库只要调用 API 就能展现内容，而数据驱动框架需要我们自己去完成内容的呈现，所以，它在使用上就没有图表库那么方便了。

使用图表库和使用数据驱动框架的具体过程和差别，我这里准备了一个对比图，你可以看一下。

使用图表库（左）和使用数据驱动框架（右）渲染图表的流程对比

不过这么讲还是比较抽象，接下来，我们还是通过绘制条形图和力导向图，来体会用数据驱动框架和用图表库构建可视化图表究竟有什么区别。

课前准备

与上一节课差不多，我们还是需要使用 SpriteJS，只不过今天我们将 QCharts 换成 D3.js。

<script src="https://unpkg.com/spritejs/dist/spritejs.min.js"></script>
<script src="https://d3js.org/d3.v6.js"></script>

使用上面的代码，我们就能加载 SpriteJS 和 D3.js，用它们来完成常用图表的绘制了。

使用 D3.js 绘制条形图

我们先来绘制条形图，条形图与柱状图差不多，都是用图形的长度来表示数据的多少。只不过，横向对比的条形图，更容易让我们看到各个数据之间的大小，而纵向的柱状图可以同时比较两个变量之间的数据差别。

用 D3.js 绘制图表，不同于使用 Qcharts，我们需要创建 SpriteJS 的容器。通过前面的课程我们已经知道，SpriteJS 创建场景（Scene）对象作为其他元素的根元素容器。接下来，我们一起看下具体的操作过程。

const container = document.getElementById('stage');
const scene = new Scene({
  container,
  width: 1200,
  height: 1200,
});

如上面代码所示，我们先创建一个 Scene 对象，与 QCharts 的 Chart 对象一样，它需要一个 HTML 容器，这里我们使用页面上一个 id 为 stage 的元素。我们设置了参数 width 和 height 为 1200，也就是把 Canvas 对象的画布宽高设为 1200 * 1200。

接着，我们准备数据。与使用 QCharts 必须要按照格式给出 JSON 数据不同，使用 D3.js 的时候数据格式比较自由。这里，我们直接用了一个数组：

const dataset = [125, 121, 127, 193, 309];

然后，我们使用 D3.js 的方法对数据进行映射：

const scale = d3.scaleLinear()
  .domain([100, d3.max(dataset)])
  .range([0, 500]);

D3.js 在设计上采用了一些函数式编程思想，这里的.scaleLinear、.domain 和.range 都是高阶函数，它们返回一个 scale 函数，这个函数把一组数值线性映射到某个范围，这里，我们就是将数值映射到 500 像素区间，数值是从 100 到 309。

那么这个 scale 函数要怎么使用呢？别着急，我们先往下看。

有了数据 dataset 和处理数据的 scale 方法之后，我们使用 d3-selection（这是 d3 中的一个子模块，我们是通过 CDN 来加载 d3 的，所以已经默认包含了 d3-selection）来创建并选择 layer 对象。

在 SpriteJS 中，场景 Scene 可以由多个 Layer 构成，针对每个 Layer 对象，SpriteJS 都会创建一个实际的 Canvas 画布。

const fglayer = scene.layer('fglayer');
const s = d3.select(fglayer);

如上面的代码所示，我们先创建了一个 fglayer，它对应一个 Canvas 画布，然后通过 d3.select(fglayer)，将对应的 fglayer 元素经过 d3 包装后返回。

接着，我们在 fglayer 元素上进行迭代操作。你先认真看完代码，我再来解释。

const colors = ['#fe645b', '#feb050', '#c2af87', '#81b848', '#55abf8'];
const chart = s.selectAll('sprite')
  .data(dataset)
  .enter()
  .append('sprite')
  .attr('x', 450)
  .attr('y', (d, i) => {
    return 200 + i * 95;
  })
  .attr('width', scale)
  .attr('height', 80)
  .attr('bgcolor', (d, i) => {
    return colors[i];
  });

我们从第 2 行代码开始看，其中，selectAll 用来返回 fglayer 下的 sprite 子元素，对于 SpriteJS 来说，sprite 元素是基本元素，用来表示一个图形。不过，现在 fglayer 下还没有任何子元素，所以 selectAll(‘sprite’) 本应该返回空的元素，但是，d3 通过 data 方法迭代数据集，也就是之前有 5 个元素的数组，然后通过执行 enter() 和 append(‘sprite’)，这样就在 fglayer 下添加了 5 个 sprite 子元素。enter() 方法是告诉 d3-selection，当数据集的数量大于 selectAll 选中的元素数量时，通过 append 添加元素补齐数量。

从第 6 行代码开始，我们给每个 sprite 元素迭代设置属性。注意，append 之后的 attr 是默认迭代设置每个 sprite 元素的属性，如果是常量就直接设置，如果是不同的值，就通过迭代算子来设置。迭代算子有两个参数，第一个是 dataset 中对应的数据，第二个是迭代次数，从 0 开始，因为有五项数据，所以会迭代 5 次。如果你对 jQuery 比较熟悉，你应该能比较容易理解上面这种批量迭代操作的形式。

最后，我们根据数据集的每个数据依次设置一个 sprite 元素，将 x 坐标值设置为 450，y 坐标值设置为从 200 开始，每个元素占据 95 个像素值，然后将 width 设置为用 scale 计算后的数据项的值，这里我们就用到前面 linearScale 高阶函数生成的 scale 函数，直接将它作为算子。我们将 height 值设为固定的 80，表示元素的高度。这样一来，元素之间就会有 95 - 80，即 15 像素的空隙。最后我们给元素设置一组不同的颜色值。

我们最终显示出来的效果如下图：

这里我们在画布上显示了五个不同颜色的矩形条，它们对应数组的 125、121、127、193、309。但它还不是一个完整的图表，我们需要给它增加辅助信息，比如坐标轴。添加坐标轴的代码如下所示。

const axis = d3.axisBottom(scale).tickValues([100, 200, 300]);
const axisNode = new SpriteSvg({
  x: 420,
  y: 680,
});
d3.select(axisNode.svg)
  .attr('width', 600)
  .attr('height', 60)
  .append('g')
  .attr('transform', 'translate(30, 0)')
  .call(axis);

axisNode.svg.children[0].setAttribute('font-size', 20);
fglayer.append(axisNode);

如上面代码所示，我们通过 d3.axisBottom 创建一个底部的坐标。我们可以通过 tickValues 给坐标轴传要显示的刻度值，这里我们显示 100、200、300 三个刻度。同样我们可以用 scale 函数将这些数值线性映射到 500 像素区间，值从 100 到 309。

axisBottom 本身是一个高阶函数，它返回 axis 函数用来绘制坐标轴，不过这个函数是使用 svg 来绘制坐标轴的。好在 SpriteJS 支持 SpriteSvg 对象，它可以绘制一个 SVG 图形，然后将这个图形以 WebGL 或者 Canvas2D 的方式绘制到画布上。

我们先创建 SpriteSvg 类的对象 axisNode，然后通过 d3.select 选中对象的 svg 属性，进行正常的 svg 属性设置和创建 svg 元素操作，最终将 axisNode 添加到 fglayer 上，这样就能将坐标轴显示出来了。

这样，我们就实现了一个简陋的条形图。简陋是因为和 QCharts 的柱状图相比，它现在只有图形主体部分和一个简单的 x 坐标轴，缺少 y 坐标轴、图例、提示信息、辅助网格等信息，不过这些用 D3.js 也都能创建，我觉得这部分内容，你可以自己试着实现，我就不多说了，如果遇到问题记得在留言区提问。

总的来说，在创建简单的图表的时候，使用 D3.js 比直接使用图表库还是要复杂很多的。但比较好的一点是，D3.js 对数据格式没有太多硬性要求，我们可以直接使用一个简单的数组，然后在后面绘图的时候再进行迭代。那麻烦一点的是，因为没有现成的图表对象，所以我们要自己处理数据、显示属性的映射，好在 D3.js 提供了 linearScale 这样的工具函数来创建数据映射。

处理好数据映射之后，我们需要自己通过 d3-selection 来遍历元素，完成属性的设置，从而把图形渲染出来。而且，对于坐标轴等其他附属信息，d3 也没有现成的对象，我们也需要通过遍历元素进行绘制。

这里顺便提一下，虽然我们使用 SpriteJS 作为图形库来讲解，但 d3 并没有强制限定图形库，所以我们无论是采用 SVG、原生 Canvas2D 还是 WebGL，又或者是采用 ThreeJS 等其他图形库，都可以进行渲染。只不过，d3-selection 依赖于 DOM 操作，所以 SVG 和 SpriteJS 这种与 DOM API 保持一致的图形系统，使用起来会更加方便一些。

使用 D3.js 绘制力导向图

讲完了用 D3.js 绘制简单条形图的方法，接下来，我们看看怎么用 D3.js 绘制更加复杂的图形，比如力导向图。

力导向图也是一种比较常见的可视化图表，它非常适合用来描述关系型信息。比如下图就是一个经典的力导向图应用。

我们看到，力导向图不仅能够描绘节点和关系链，而且在移动一个节点的时候，图表各个节点的位置会跟随移动，避免节点相互重叠。

那么究竟如何用 D3.js 实现一个简单的力导向图呢？我们来看一个例子。

力导向图，顾名思义，我们通过模拟节点之间的斥力，来保证节点不会相互重叠。在 D3.js 中提供了模拟斥力的方法。

const simulation = d3.forceSimulation()
  .force('link', d3.forceLink().id(d => d.id)) //节点连线
  .force('charge', d3.forceManyBody()) // 多实体作用
  .force('center', d3.forceCenter(400, 300)); // 力中心

如上面代码所示，我们创建一个 d3 的力模型对象 simulation，通过它来模拟示例，然后我们设置节点连接、多实体相互作用、力中心点。

接着，我们读取数据。这里我准备了一份JSON 数据。我们可以用 d3.json 来读取数据，它返回一个 Promise 对象。

d3.json('https://s0.ssl.qhres.com/static/f74a79ccf53d8147.json').then(graph => {
  ...
});

我们先用力模型来处理数据：

simulation
  .nodes(graph.nodes)
  .on('tick', ticked);

simulation.force('link')
  .links(graph.links);

接着，我们再绘制节点：

d3.select(layer).selectAll('sprite')
  .data(graph.nodes)
  .enter()
  .append('sprite')
  .attr('pos', (d) => {
    return [d.x, d.y];
  })
  .attr('size', [10, 10])
  .attr('border', [1, 'white'])
  .attr('borderRadius', 5)
  .attr('anchor', 0.5);

然后，我们再绘制连线：

d3.select(layer).selectAll('path')
  .data(graph.links)
  .enter()
  .append('path')
  .attr('d', (d) => {
    const [sx, sy] = [d.source.x, d.source.y];
    const [tx, ty] = [d.target.x, d.target.y];
    return `M${sx} ${sy} L ${tx} ${ty}`;
  })
  .attr('name', (d, index) => {
    return `path${index}`;
  })
  .attr('strokeColor', 'white');

这里我们依然是用 d3-selection 的迭代，给 SpriteJS 的 sprite 和 path 元素设置了一些属性，这些属性有的与我们的数据建立关联，有的是单纯的样式。这里面没有特别难的地方，我就不一一解释了，最好的理解方法是实践，所以我建议你亲自研究一下示例代码，修改一些属性，看看结果有什么变化，这样能够加深理解。

将节点和连线绘制完成之后，力导向图的初步结果就呈现出来了。

因为力向导图有一个特点就是，在我们移动一个节点的时候，其他节点也会跟着移动。所以，我们还要实现拖动节点的功能。D3.js 支持处理拖拽事件，所以我们只要分别实现一下对应的事件回调函数，完成时间注册就可以了。首先是三个事件回调函数。

function dragstarted(event) {
  if(!event.active) simulation.alphaTarget(0.3).restart();

  const [x, y] = [event.subject.x, event.subject.y];
  event.subject.fx0 = x;
  event.subject.fy0 = y;
  event.subject.fx = x;
  event.subject.fy = y;

  const [x0, y0] = layer.toLocalPos(event.x, event.y);
  event.subject.x0 = x0;
  event.subject.y0 = y0;
}

function dragged(event) {
  const [x, y] = layer.toLocalPos(event.x, event.y),
    {x0, y0, fx0, fy0} = event.subject;
  const [dx, dy] = [x - x0, y - y0];

  event.subject.fx = fx0 + dx;
  event.subject.fy = fy0 + dy;
}

function dragended(event) {
  if(!event.active) simulation.alphaTarget(0);
  event.subject.fx = null;
  event.subject.fy = nul

其中 dragstarted 处理开始拖拽的事件，这个时候，我们通过前面创建的 simulation 对象启动力模拟，记录一下当前各个节点的 x、y 坐标。因为默认的坐标是 DOM 事件坐标，我们通过 layer.toLocalPos 方法将它转换成相对于 layer 的坐标。接着 dragged 处理拖拽中的事件，同样也是转换 x、y 坐标，计算出坐标的差值，然后更新 fx、fy，也就是事件主体的当前坐标。最后，我们用 dragended 处理拖住结束事件，清空 fx 和 fy。

接着，我们将三个事件处理函数注册到 layer 的 canvas 上：

  d3.select(layer.canvas)
    .call(d3.drag()
      .container(layer.canvas)
      .subject(dragsubject)
      .on('start', dragstarted)
      .on('drag', dragged)
      .on('end', dragended));

这样就实现了力导向图拖拽节点的交互，d3 会自动根据新的节点位置计算布局，避免节点的重叠。

要点总结

这节课，我们主要学习了使用数据驱动框架来绘制图表。

与直接使用图表库不同，使用数据驱动框架不要求固定格式的数据格式，而是通过对原始数据的处理和对容器迭代、创建新的子元素，并且根据数据设置属性，来完成从数据到元素结构和属性的映射，然后再用渲染引擎将它最终渲染出来。

那你可能有疑问了，我们应该在什么时候选择图表库，什么时候选择数据驱动框架呢？通常情况下，当需求比较明确可以用图表库，并且样式通过图表库 API 设置可以实现的时候，我们倾向于使用图表库，但是当需求比较复杂，或者样式要求灵活多变的时候，我们可以考虑使用数据驱动框架。

数据驱动框架可以灵活实现各种复杂的图表效果，我们前面举的两个图表例子虽然只是个例，但也会在实战项目中经常用到。除此之外，使用 D3.js 和 SpriteJS 还可以实现其他复杂的图表，比如说，地图或者一些 3D 图表，以及我们在前面的课程中实现的 3Dgithub 代码贡献图，就是使用 D3.js 和 SpriteJS 来实现的。

D3.js 和 SpriteJS 的使用都比较复杂，你是不可能用一节课系统掌握的，我们只有继续深入学习，并动手实践、积累经验，才能在可视化项目中得心应手地使用它们，来实现各种各样的可视化需求。

小试牛刀

最后，我给你出了两个实践题。希望你能结合 D3.js 和 SpriteJS 的官方文档，花点时间仔细阅读和学习，再通过动手实践和反复练习，最终掌握它们。

请你完善我们课程中讲到的条形图，给它实现 y 轴、图例和提示信息。

你可以将上一节课用 QCharts 图表库实现的图表改用 D3.js 实现吗？动手试一试，体会一下它们使用方式和思路上的不同。

关于可视化图表的实战课程就讲到这里了，如果你对于图表绘制，还有什么疑问和困惑，欢迎你在留言区告诉我。我们下节课再见！

源码

课程中完整示例代码详见GitHub 仓库

推荐阅读

D3.js 的官方文档

SpriteJS 的官方文档

39 | 实战（三）：如何实现地理信息的可视化？

你好，我是月影。

前段时间，我们经常能看到新冠肺炎的疫情地图。这些疫情地图非常直观地呈现了世界上不同国家和地区，一段时间内的新冠肺炎疫情进展，能够帮助我们做好应对疫情的决策。实际上，这些疫情地图都属于地理位置信息可视化，而这类信息可视化的主要呈现方式就是地图。

在如今的互联网领域，地理信息可视化应用非常广泛。除了疫情地图，我们平时使用外卖订餐、春运交通、滴滴打车，这些 App 中都有地理信息可视化的实现。

那地理信息可视化该如何实现呢？今天，我们就通过一个疫情地图的实现，来讲一讲地理信息可视化该怎么实现。

假设，我们要使用世界地图的可视化，来呈现不同国家和地区，从 2020 年 1 月 22 日到 3 月 19 日这些天的新冠肺炎疫情进展。我们具体该怎么做呢？主要有四个步骤，分别是准备数据、绘制地图、整合数据和更新绘制方法。下面，我们一一来看。

步骤一：准备数据

新冠肺炎的官方数据在 WHO 网站上每天都会更新，我们可以直接找到 2020 年 1 月 22 日到 3 月 19 日的数据，将这些数据收集和整理成一份 JSON 文件。这份 JSON 文件的内容比较大，我把它放在 Github 上了，你可以去Github 仓库查看这份数据。

有了 JSON 数据之后，我们就可以将这个数据和世界地图上的国家一一对应。那接下来的任务就是准备世界地图，想要绘制一份世界地图，我们也需要有世界地图的地理数据，这也是一份 JSON 文件。

地理数据通常可以从开源社区中获取公开数据，或者从相应国家的测绘部门获取当地的公开数据。这次用到的世界地图的数据，我们是通过开源社区获得的。

一般来说，地图的 JSON 文件有两种数据格式，一种是 GeoJSON，另一种是 TopoJSON。其中 GeoJSON 是基础格式，它包含了描述地图地理信息的坐标数据。举个简单的例子：

{
    "type":"FeatureCollection",
    "features": [
        {
          "type":"Feature",
          "geometry":{
              "type":"Polygon",
              "coordinates":
              [
                  [[117.42218831167838,31.68971206252246],
                  [118.8025942451759,31.685801564127132],
                  [118.79961418869482,30.633841626314336],
                  [117.41920825519742,30.637752124709664],
                  [117.42218831167838,31.68971206252246]]
              ]
          },
          "properties":{"Id":0}
        }
    ]
}

上面的代码就是一个合法的 GeoJSON 数据，它定义了一个地图上的多边形区域，坐标是由四个包含了经纬度的点组成的（代码中一共是五个点，但是首尾两个点是重合的）。

那什么是 TopoJSON 格式呢？TopoJSON 格式就是 GeoJSON 格式经过压缩之后得到的，它通过对坐标建立索引来减少冗余，数据压缩能够大大减少 JSON 文件的体积。

因为这节课的重点主要是地理信息的可视化绘制，而 GeoJSON 和 TopJSON 文件格式的具体规范又比较复杂，不是我们课程的重点，所以我就不详细来讲了。如果你有兴趣进一步学习，可以参考我在课后给出的资料。

这节课，我们直接使用我准备好的两份世界地图的 JSON 数据就可以了，一份是GeoJSON 数据，一份是TopoJSON 数据。接下来，我们会分别来讲怎么使用它们来绘制地图。

步骤二：绘制地图

将数据绘制成地图的方法有很多种，我们既可以用 Canvas2D、也可以用 SVG，还可以用 WebGL。除了用 WebGL 相对复杂，用 Canvas2D 和 SVG 都比较简单。为了方便你理解，我选择用比较简单的 Canvas2D 来绘制地图。

首先，我们将 GeoJSON 数据中，coordinates 属性里的经纬度信息转换成画布坐标，这个转换被称为地图投影。实际上，地图有很多种投影方式，但最简单的方式是墨卡托投影，也叫做等圆柱投影。它的实现思路就是把地球从南北两极往外扩，先变成一个圆柱体，再将世界地图看作是贴在圆柱侧面的曲面，经纬度作为 x、y 坐标。最后，我们再把圆柱侧面展开，经纬度自然就被投影到平面上了。

墨卡托投影

墨卡托投影是最常用的投影方式，因为它的坐标转换非常简单，而且经过墨卡托投影之后的地图中，国家和地区的形状与真实的形状仍然保持一致。但它也有缺点，由于是从两极往外扩，因此高纬度国家的面积看起来比实际的面积要大，并且维度越高面积偏离的幅度越大。

在地图投影之前，我们先来明确一下经纬度的基本概念。经度的范围是 -180 度到 180 度，负数代表西经，正数代表东经。纬度的范围是 -90 度到 90 度，负数代表南纬，正数代表北纬。

接下来，我们就可以将经纬度按照墨卡托投影的方式转换为画布上的 x、y 坐标。对应的经纬度投影如下图所示。

注意，精度范围是 360 度，而维度范围是 180 度，而且因为 y 轴向下，所以计算 y 需要用 1.0 减一下。

所以对应的换算公式如下：

x = width * (180 + longitude) / 360;
y = height * (1.0 - (90 + latitude) / 180); // Canvas坐标系y轴朝下

其中，longitude 是经度，latitude 是纬度，width 是 Canvas 的宽度，height 是 Canvas 的高度。

那有了换算公式，我们将它封装成投影函数，代码如下：

// 将geojson数据用墨卡托投影方式投影到1024*512宽高的canvas上
const width = 1024;
const height = 512;

function projection([longitude, latitude]) {
  const x = width * (180 + longitude) / 360;
  const y = height * (1.0 - (90 + latitude) / 180); // Canvas坐标系y轴朝下
  return [x, y];
}

有了投影函数之后，我们就可以读取和遍历 GeoJSON 数据了。

我们用 fetch 来读取 JSON 文件，将它包含地理信息的字段取出来。根据 GeoJSON 规范，这个字段是 features 字段，类型是数组，然后我们通过 forEach 方法遍历这个数组。

(async function () {
  const worldData = await (await fetch('./assets/data/world-geojson.json')).json();
  const features = worldData.features;
  features.forEach(({geometry}) => {
    ...遍历数据
    ...进行投影转换
    ...进行绘制
  });
}();

在 forEach 迭代的时候，我们可以拿到 features 数组中每一个元素里的 geometry 字段，这个字段中包含有 coordinates 数组，coordinates 数组中的值就是经纬度值，我们可以对这些值进行投影转换，最后调用 drawPoints 将这个数据画出来。绘制过程十分简单，你直接看下面的代码就可以理解。

function drawPoints(ctx, points) {
  ctx.beginPath();
  ctx.moveTo(...points[0]);
  for(let i = 1; i < points.length; i++) {
    ctx.lineTo(...points[i]);
  }
  ctx.fill();
}

完整的代码我放在了GitHub 仓库中，你可以下载到本地运行。这里，我直接把运行的结果展示给你看。

以上，就是利用 GeoJSON 数据绘制地图的全过程。这个过程非常简单，我们只需要将 coordinate 数据进行投影，然后根据投影的坐标把轮廓绘制出来就可以了。但是，GeoJSON 数据通常比较大，如果我们直接在 Web 应用中使用，有些浪费带宽，也可能会导致网络加载延迟，所以，使用 TopoJSON 数据是一个更好的选择。

举个例子，同样的世界地图数据，GeoJSON 格式数据有 251KB，而经过了压缩的 TopoJSON 数据只有 84KB，体积约为原来的 1/3。

尽管体积比 GeoJSON 数据小了不少，但是 TopoJSON 数据经过了复杂的压缩之后，我们在使用的时候还需要对它解压，把它变成 GeoJSON 数据。可是，如果我们自己写代码去解压，实现起来比较复杂。好在，我们可以采用现成的工具对它进行解压。这里，我们可以使用 GitHub 上的TopoJSON 官方仓库的 JavaScript 模块来处理 TopoJSON 数据。

这个转换简单到只用一行代码就可以完成，转换完成之后，我们就可以用同样的方法将世界地图绘制出来了。具体的转换过程我就不多说了，你可以自己试一试。转换代码如下：

const countries = topojson.feature(worldData, worldData.objects.countries);

步骤三：整合数据

有了世界地图之后，下一步就是将疫情的 JSON 数据整合进地图数据里面。

在 GeoJSON 或者 TopoJSON 解压后的 countries 数据中，除了用 geometries 字段保存地图的地区信息外，还用 properties 字段来保存了其他的属性。在我们这一份地图数据中，properties 只有一个 name 属性，对应着不同国家的名字。

我们打开TopoJSON 文件就可以看到在 contries.geometries 下的 properties 属性中有一个 name 属性，对应国家的名字。

这个时候，我们再打开疫情的 JSON 数据，我们会发现疫情数据中的 contry 属性和 GeoJSON 数据里面的国家名称是一一对应的。

这样，我们就可以建立一个数据映射关系，将疫情数据中的每个国家的疫情数据直接写入到 GeoJSON 数据的 properties 字段里面。

接着，我们增加一个数据映射函数：

function mapDataToCountries(geoData, convidData) {
  const convidDataMap = {};
  convidData.dailyReports.forEach((d) => {
    const date = d.updatedDate;
    const countries = d.countries;
    countries.forEach((country) => {
      const name = country.country;
      convidDataMap[name] = convidDataMap[name] || {};
      convidDataMap[name][date] = country;
    });
  });
  geoData.features.forEach((d) => {
    const name = d.properties.name;
    d.properties.convid = convidDataMap[name];
  });
}

在这个函数里，我们先将疫情数据的数组转换成以国家名为 key 的 Map，然后将它写入到 TopoJSON 读取出的 Geo 数据对象里。

最后，我们直接读取两个 JSON 数据，调用这个数据映射函数就完成了数据整合。

const worldData = await (await fetch('./assets/data/world-topojson.json')).json();
const countries = topojson.feature(worldData, worldData.objects.countries);

const convidData = await (await fetch('./assets/data/convid-data.json')).json();
mapDataToCountries(countries, convidData);

因为整合好的数据比较多，所以我只在这里列出一个国家的示例数据：

{
  "objects": {
  "countries": {
    "type": "GeometryCollection",
    "geometries": [{
      "arcs": [
        [0, 1, 2, 3, 4, 5]
      ],
      "type": "Polygon",
      "properties": {
        "name": "Afghanistan",
        "convid": {
          "2020-01-22": {
            "confirmed": 1,
            "recovered": 0,
            "death": 0,
          },
          "2020-01-23": {
            ...
          },
          ...
        }
      }
    },
  ...

步骤四：将数据与地图结合

将全部数据整合到地理数据之后，我们就可以将数据与地图结合了。在这里，我们设计用不同的颜色来表示疫情的严重程度，填充地图，确诊人数越多的区域颜色越红。要实现这个效果，我们先要创建一个确诊人数和颜色的映射函数。

我把无人感染到感染人数超过 10000 人划分了 7 个等级，每个等级用不同的颜色表示：

若该地区无人感染，渲染成 #3ac 颜色

若该地区感染人数小于 10，渲染成 rgb(250, 247, 171) 色

若该地区感染人数 10~99 人，渲染成 rgb(255, 186, 66) 色

若该地区感染人数 100~499 人，渲染成 rgb(234, 110, 41) 色

若该地区感染人数 500~999 人，渲染成 rgb(224, 81, 57) 色

若该地区感人人数 1000~9999 人，渲染成 rgb(192, 50, 39) 色

若该地区感染人数超 10000 人，渲染成 rgb(151, 32, 19) 色

对应的代码如下：

function mapColor(confirmed) {
  if(!confirmed) {
    return '#3ac';
  }
  if(confirmed < 10) {
    return 'rgb(250, 247, 171)';
  }
  if(confirmed < 100) {
    return 'rgb(255, 186, 66)';
  }
  if(confirmed < 500) {
    return 'rgb(234, 110, 41)';
  }
  if(confirmed < 1000) {
    return 'rgb(224, 81, 57)';
  }
  if(confirmed < 10000) {
    return 'rgb(192, 50, 39)';
  }
  return 'rgb(151, 32, 19)';
}

然后，我们在绘制地图的代码里根据确诊人数设置 Canvas 的填充信息：

function drawMap(ctx, countries, date) {
  date = formatDate(date); // 转换日期格式

  countries.features.forEach(({geometry, properties}) => {
    ... 读取当前日期下的确诊人数

    ctx.fillStyle = mapColor(confirmed); // 映射成地图颜色并设置到Canvas上下文

    ... 执行绘制
  });

我们先把 data 参数设为’2020-01-22’，这样一来，我们就绘制出了 2020 年 1 月 22 日的疫情地图。

2020年1月22日疫情地图

可是，疫情的数据每天都会更新，如果想让疫情地图随着日期自动更新，我们该怎么做呢？我们可以给地图绘制过程加上一个定时器，这样我们就能得到一个动态的疫情地图了，它会自动显示从 1 月 22 日到当前日期疫情变化。这样，我们就能看到疫情随时间的变化了。

const startDate = new Date('2020/01/22');
let i = 0;
const timer = setInterval(() => {
  const date = new Date(startDate.getTime() + 86400000 * (++i));
  drawMap(ctx, countries, date);
  if(date.getTime() + 86400000 > Date.now()) {
    clearInterval(timer);
  }
}, 100);
drawMap(ctx, countries, startDate);

要点总结

这节课，我们讲了实现地理信息可视化的通用步骤，一共可以分为四步，我们一起来回顾一下。

第一步是准备数据，包括地图数据和要可视化的数据。地图数据有 GeoJSON 和 TopoJSON 两个规范。相比较而言，TopoJSON 数据格式经过了压缩，体积会更小，比较适合 Web 应用。

第二步是绘制地图。要绘制地图，我们需要将地理信息中的坐标信息投影到地图上，最简单的投影方式是使用墨卡托投影。

第三步是整合数据，我们要把可视化数据和地图的地理数据集成到一起，这一步我们可以通过定义数据映射函数来实现。

最后一步，就是将数据与地图结合，根据整合后的数据结合地图完成最终的图形绘制。

总的来说，无论我们要实现多么复杂的地理信息可视化地图，核心的 4 个步骤是不会变的，只不过其中的每一步，我都可以替换具体的实现方式，比如，我们可以使用其他的投影方式来代替墨卡托投影，来绘制不同形状的地图。

课后练习

我们今天选择使用 Canvas 来绘制地图，是因为它使用起来十分方便。其实，使用 SVG 绘制地图也很方便，你能试着改用 SVG 来实现今天的疫情地图吗？这和使用 Canvas 有什么共同点和不同点？

我们今天使用的墨卡托投影是最简单的投影方法，它的缺点是让高纬度下的国家看起来比实际的要大很多。你能试着使用 D3.js 的d3-geo模块中提供的其他投影方式来实现地图吗？

3. 如果 我们要增加交互，让鼠标移动到某个国家区域的时候，这个区域高亮，并且显示国家名称、疫情确诊数、治愈数以及死亡数，这该怎么处理呢？你可以尝试增加这样的交互功能，来完善我们的地图应用吗？

好啦，今天的地理信息可视化实战就到这里了。欢迎你把实现的地图作品放在留言区，也欢迎把这节课转发出去，我们下节课见！

源码

[1] 新冠肺炎数据

[2] GeoJSON 数据

[3] TopoJSON 数据

[4] 完整的示例代码见GitHub 仓库

推荐阅读

[1] GeoJSON 标准格式学习

[2] [GeoJSON 和 TopoJSON]reference_end

[3] GeoJSON 规范

[4] TopoJSON 规范

40| 实战（四）：如何实现3D地球可视化（上）？

你好，我是月影。

前几节课我们一起进行了简单图表和二维地图的实战，这节课，我们来实现更炫酷的 3D 地球可视化。

3D 地球可视化主要是以 3D 的方式呈现整个地球的模型，视觉上看起来更炫酷。它是可视化应用里常见的一种形式，通常用来实现全球地理信息相关的可视化应用，例如全球黑客攻防示意图、全球航班信息示意图以及全球贸易活动示意图等等。

因为内容比较多，所以我会用两节课来讲解 3D 地球的实现效果。而且，由于我们的关注点在效果，因此为了简化实现过程和把重点聚焦在效果上，我就不刻意准备数据了，我们用一些随机数据来实现。不过，即使我们要实现的是包含真实数据的 3D 可视化应用项目，前面学过的数据处理方法仍然是适用的。这里，我就不多说了。

在学习之前，你可以先看一下我们最终要实现的 3D 地球可视化效果，先有一个直观的印象。

如上面动画图像所示，我们要做的 3D 可视化效果是一个悬浮于宇宙空间中的地球，它的背后是一些星空背景和浅色的光晕，并且地球在不停旋转的同时，会有一些不同的地点出现飞线动画。

接下来，我们就来一步步实现这样的效果。

如何实现一个 3D 地球

第一步，我们自然是要实现一个旋转的地球。通过前面课程的学习，我们知道直接用 SpriteJS 的 3D 扩展就可以方便地绘制 3D 图形。这里，我们再系统地说一下实现的方法。

1. 绘制一个 3D 球体

首先，我们加载 SpriteJS 和 3D 扩展，最简单的方式还是直接使用 CDN 上打包好的文件，代码如下：

<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>

加载完成之后，我们创建场景对象，添加 Layer，代码如下：

const {Scene} = spritejs;
const container = document.getElementById('container');
const scene = new Scene({
  container,
});
const layer = scene.layer3d('fglayer', {
  alpha: false,
  camera: {
    fov: 35,
    pos: [0, 0, 5],
  },
});

与 2D 的 Layer 不同，SpriteJS 的 3D 扩展创建的 Layer 需要设置相机。这里，我们设置了一个透视相机，视角为 35 度，位置为 0, 0, 5

接着是创建 WebGL 的 Program，我们通过 Layer 对象的 createProgram 来创建，代码如下：

const {Sphere, shaders} = spritejs.ext3d;

const program = layer.createProgram({
  ...shaders.GEOMETRY,
  cullFace: null,
});

SpriteJS 的 3D 扩展内置了一些常用的 Shader，比如 shaders.GEOMETRY 就是一个符合 Phong 反射模型的几何体 Shader，所以这次，我们直接使用它。

接着，我们创建一个球体，它在 SpriteJS 的 3D 扩展中对应 Sphere 对象。

const globe = new Sphere(program, {
  colors: '#333',
  widthSegments: 64,
  heightSegments: 32,
  radius: 1,
});

layer.append(globe);

我们给球体设置颜色、宽度、高度和半径这些默认的属性，然后将它添加到 layer 上，这样我们就能在画布上将这个球体显示出来了，效果如下所示。

现在，我们只在画布上显示了一个灰色的球体，它和我们要实现的地球还相差甚远。别着急，我们一步一步来。

2. 绘制地图

上节课，我们已经讲了绘制平面地图的方法，就是把表示地图的 JSON 数据利用墨卡托投影到平面上。接下来，我们也要先绘制一张平面地图，然后把它以纹理的方式添加到我们创建的 3D 球体上。

不过，与平面地图采用墨卡托投影不同，作为纹理的球面地图需要采用等角方位投影(Equirectangular Projection)。d3-geo 模块中同样支持这种投影方式，我们可以直接加载 d3-geo 模块，然后使用对应的代码来创建投影。

从 CDN 加载 d3-geo 模块需要加载以下两个 JS 文件：

<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>

然后，我们创建对应的投影：

const mapWidth = 960;
const mapHeight = 480;
const mapScale = 4;

const projection = d3.geoEquirectangular();
projection.scale(projection.scale() * mapScale).translate([mapWidth * mapScale * 0.5, (mapHeight + 2) * mapScale * 0.5]);

这里，我们首先通过 d3.geoEquirectangular 方法来创建等角方位投影，再将它进行缩放。d3 的地图投影默认宽高为 960 * 480，我们将投影缩放为 4 倍，也就是将地图绘制为 3480 * 1920 大小。这样一来，它就能在大屏上显示得更清晰。

然后，我们通过 tanslate 将中心点调整到画布中心，因为 JSON 的地图数据的 0,0 点在画布正中心。仔细看我上面的代码，你会注意到我们在 Y 方向上多调整一个像素，这是因为原始数据坐标有一点偏差。

通过我刚才说的这些步骤，我们就创建好了投影，接下来就可以开始绘制地图了。我们从 topoJSON 数据加载地图。

async function loadMap(src = topojsonData, {strokeColor, fillColor} = {}) {
  const data = await (await fetch(src)).json();
  const countries = topojson.feature(data, data.objects.countries);
  const canvas = new OffscreenCanvas(mapScale * mapWidth, mapScale * mapHeight);
  const context = canvas.getContext('2d');
  context.imageSmoothingEnabled = false;
  return drawMap({context, countries, strokeColor, fillColor});
}

这里我们创建一个离屏 Canvas，用加载的数据来绘制地图到离屏 Canvas 上，对应的绘制地图的逻辑如下：

function drawMap({
  context,
  countries,
  strokeColor = '#666',
  fillColor = '#000',
  strokeWidth = 1.5,
} = {}) {
  const path = d3.geoPath(projection).context(context);

  context.save();
  context.strokeStyle = strokeColor;
  context.lineWidth = strokeWidth;
  context.fillStyle = fillColor;
  context.beginPath();
  path(countries);
  context.fill();
  context.stroke();
  context.restore();

  return context.canvas;

这样，我们就完成了地图加载和绘制的逻辑。当然，我们现在还看不到地图，因为我们只是将它绘制到了一个离屏的 Canvas 对象上，并没有将这个对象显示出来。

3. 将地图作为纹理

要显示地图为 3D 地球，我们需要将刚刚绘制的地图作为纹理添加到之前绘制的球体上。之前我们绘制球体时，使用的是 SpriteJS 中默认的 shader，它是符合 Phong 光照模型的几何材质的。因为考虑到地球有特殊光照，我们现在自己实现一组自定义的 shader。

const vertex = `
  precision highp float;
  precision highp int;

  attribute vec3 position;
  attribute vec3 normal;
  attribute vec4 color;
  attribute vec2 uv;

  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;

  varying vec3 vNormal;
  varying vec2 vUv;
  varying vec4 vColor;

  uniform vec3 pointLightPosition; //点光源位置

  void main() {
    vNormal = normalize(normalMatrix * normal);

    vUv = uv;
    vColor = color;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = `
  precision highp float;
  precision highp int;

  varying vec3 vNormal;
  varying vec4 vColor;

  uniform sampler2D tMap;
  varying vec2 vUv;

  uniform vec2 uResolution;

  void main() {
    vec4 color = vColor;
    vec4 texColor = texture2D(tMap, vUv);
    vec2 st = gl_FragCoord.xy / uResolution;

    float alpha = texColor.a;
    color.rgb = mix(color.rgb, texColor.rgb, alpha);
    color.rgb = mix(texColor.rgb, color.rgb, clamp(color.a / max(0.0001, texColor.a), 0.0, 1.0));
    color.a = texColor.a + (1.0 - texColor.a) * color.a;

    float d = distance(st, vec2(0.5));

    gl_FragColor.rgb = color.rgb + 0.3 * pow((1.0 - d), 3.0);
    gl_FragColor.a = c

我们用上面的 Shader 来创建 Program。这组 Shader 并不复杂，原理我们在视觉篇都已经解释过了。如果你觉得理解起来依然有困难，可以复习一下视觉篇的内容。接着，我们创建一个 Texture 对象，将它赋给 Program 对象，代码如下。

const texture = layer.createTexture({});

const program = layer.createProgram({
  vertex,
  fragment,
  texture,
  cullFace: null,
});

现在，画布上就显示出了一个中心有些亮光的球体。

从中，我们还是看不出地球的样子。这是因为我们给的 texture 对象是一个空的纹理对象。接下来，我们只要执行 loadMap 方法，将地图加载出来，再添加给这个空的纹理对象，然后刷新画布就可以了。对应代码如下：

loadMap().then((map) => {
  texture.image = map;
  texture.needsUpdate = true;
  layer.forceUpdate();
});

最终，我们就显示出了地球的样子。

我们还可以给地球添加轨迹球控制，并让它自动旋转。在 SpriteJS 中非常简单，只需要一行代码即可完成。

layer.setOrbit({autoRotate: true}); // 开启旋转控制

这样我们就得到一个自动旋转的地球效果了。

如何实现星空背景

不过，这个孤零零的地球悬浮在黑色背景的空间里，看起来不是很吸引人，所以我们可以给地球添加一些背景，比如星空，让它真正悬浮在群星闪耀的太空中。

要实现星空的效果，第一步是要创建一个天空包围盒。天空包围盒也是一个球体（Sphere）对象，只不过它要比地球大很多，以此让摄像机处于整个球体内部。为了显示群星，天空包围盒有自己特殊的 Shader。我们来看一下：

const skyVertex = `
  precision highp float;
  precision highp int;

  attribute vec3 position;
  attribute vec3 normal;
  attribute vec2 uv;

  uniform mat3 normalMatrix;
  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;

  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const skyFragment = `
  precision highp float;
  precision highp int;
  varying vec2 vUv;

  highp float random(vec2 co)
  {
    highp float a = 12.9898;
    highp float b = 78.233;
    highp float c = 43758.5453;
    highp float dt= dot(co.xy ,vec2(a,b));
    highp float sn= mod(dt,3.14);
    return fract(sin(sn) * c);
  }

  // Value Noise by Inigo Quilez - iq/2013
  // https://www.shadertoy.com/view/lsf3WH
  highp float noise(vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix( mix( random( i + vec2(0.0,0.0) ),
                    random( i + vec2(1.0,0.0) ), u.x),
                mix( random( i + vec2(0.0,1.0) ),
                    random( i + vec2(1.0,1.0) ), u.x), u.y);
  }

  void main() {
    gl_FragColor.rgb = vec3(1.0);
    gl_FragColor.a = step(0.93, noise(vUv * 6000.0));

上面的代码是天空包围盒的 Shader，实际上它是我们使用二维噪声的技巧来实现的。在第 16 节课中也有过类似的做法，当时我们是用它来模拟水滴滚过的效果。

但在这里，我们通过 step 函数和 vUv 的缩放，将它缩小之后，最终呈现出来星空效果。

对应的创建天空盒子的 JavaScript 代码如下：

function createSky(layer, skyProgram) {
  skyProgram = skyProgram || layer.createProgram({
    vertex: skyVertex,
    fragment: skyFragment,
    transparent: true,
    cullFace: null,
  });
  const skyBox = new Sphere(skyProgram);
  skyBox.attributes.scale = 100;
  layer.append(skyBox);
  return skyBox;
}

createSky(layer);

不过，光看这些代码，你可能还不能完全明白，为什么二维噪声技巧就能实现星空效果。那也不要紧，完整的示例代码在GitHub 仓库中，最好的理解方式还是你自己试着手动修改一下 skyFragment 中的绘制参数，看看实现出来效果，你就能明白了。

要点总结

这节课，我们讲了实现 3D 地球可视化效果的方法，以及给 3D 地球添加天空背景的方法。

要实现 3D 地球效果，我们可以使用 SpriteJS 和它的 3D 扩展库。首先，我们绘制一个 3D 球体。然后，我们用 topoJSON 数据绘制地图，注意地图的投影方式必须选择等角方位投影。最后，我们把地图作为纹理添加到 3D 球体上，这样就绘制出了 3D 地球。

而要实现星空背景，我们需要创建一个天空盒子，它可以看成是一个放大很多倍的球体，包裹在地球的外面。具体的思路就是，我们创建一组特殊的 Shader，通过二维噪声来实现星空的效果。

说的这里，你可能会有一些疑问，我们为什么要用 topoJSON 数据来绘制地图，而不采用现成的等角方位投影的平面地图图片，直接用它来作为纹理，那样不是能够更快绘制出 3D 地球吗？的确，这样确实也能够更简单地绘制出 3D 地球，但这么做也有代价，就是我们没有地图数据就不能进一步实现交互效果了，比如说，点击某个地理区域实现当前国家地区的高亮效果了。

那在下节课，我们就会进一步讲解怎么在 3D 地球上添加交互效果，以及根据地理位置来放置各种记号。你的疑问也都会一一解开。

小试牛刀

我们说，如果不考虑交互，可以直接使用更简单的等角方位投影地图作为纹理来直接绘制 3D 地球。你能试着在网上搜索类似的纹理图片来实现 3D 地球效果吗？

另外，你可以找类似的其他行星的图片，比如火星、木星图片来实现 3D 火星、木星的效果吗？

最后，你也可以想想，除了星空背景，如果我们还想在地球外部实现一层淡绿色的光晕，又该怎么做呢（提示：你可以使用距离场和颜色插值来实现）？

今天的 3D 地球可视化实战就到这里了。欢迎把你实现的效果分享到留言区，我们一起交流。也欢迎把这节课转发出去，我们下节课见！

源码

课程完整示例代码详

41 | 实战（五）：如何实现3D地球可视化（下）？

你好，我是月影。

上节课，我们实现了一个有着星空背景的 3D 地球效果。但这个效果还比较简单，在某些可视化大屏项目中，我们不仅要呈现视觉效果，还要允许用户与可视化大屏中呈现的内容进行交互。所以这节课，我们会先给这个 3D 地球添加各种交互细节，比如让地球上的国家随着我们鼠标的移动而高亮，接着，我们再在地球上放置各种记号，比如光柱、地标等等。

如何选中地球上的地理位置？

我们先来解决上节课留下的一个问题，为什么我们在绘制 3D 地球的时候，要大费周章地使用 topoJSON 数据，而不是直接用一个现成的等角方位投影的世界地图图片作为球体的纹理。这是因为，我们想让地球能够和我们的鼠标进行交互，比如当点击到地图上的中国区域的时候，我们想让中国显示高亮，这是纹理图片无法实现的。接下来，我们就来看看怎么实现这样交互的效果。

实现坐标转换

实现交互效果的难点在于坐标转换。因为鼠标指向地球上的某个区域的时候，我们通过 SpriteJS 拿到的是当前鼠标在点击的地球区域的一个三维坐标，而这个坐标是不能直接判断点中的区域属于哪个国家的，我们需要将它转换成二维的地图经纬度坐标，才能通过地图数据来获取到当前经纬度下的国家或地区信息。

那如何实现这个坐标转换呢？首先，我们的鼠标在地球上移动的时候，通过 SpriteJS，我们拿到三维的球面坐标，代码如下：

layer.setRaycast();

globe.addEventListener('mousemove', (e) => {
  console.log(e.hit.localPoint);
});
...
skyBox.attributes.raycast = 'none';

上面的代码中有一个小细节，我们将天空包围盒的 raycast 设置成了 none。为什么要这么做呢？因为地球包围在天空盒子内，这样设置之后，鼠标就能穿透天空包围盒到达地球，如果不这么做，天空盒子就会遮挡住鼠标事件，地球也就捕获不到事件了。这样一来，当鼠标移动到地球上时，我们就可以得到相应的三维坐标信息了。

接下来，我们要将三维坐标信息转换为经纬度信息，那第一步就是将三维坐标转换为二维平面坐标。

/**
 * 将球面坐标转换为平面地图坐标
 * @param {*} x
 * @param {*} y
 * @param {*} z
 * @param {*} radius
 */
function unproject(x, y, z, radius = 1) {
  const pLength = Math.PI * 2;
  const tLength = Math.PI;
  const v = Math.acos(y / radius) / tLength; // const y = radius * Math.cos(v * tLength);
  let u = Math.atan2(-z, x) + Math.PI; // z / x = -1 * Math.tan(u * pLength);
  u /= pLength;
  return [u * mapScale * mapWidth, v * mapScale * mapHeight];
}

这个球面和平面坐标转换，实际上就是将空间坐标系从球坐标系转换为平面直接坐标系。具体的转换方法是，我们先将球坐标系转为圆柱坐标系，再将圆柱坐标系转为平面直角坐标系。具体的公式推导过程比较复杂，我们没必要深入理解，你只要会用我给出的 unproject 函数就可以了。如果你对推导原理有兴趣可以回顾第 15 课，自己来推导一下，或者阅读这篇文章。

拿到了二维平面直角坐标之后，我们可以直接用等角方位投影函数的反函数将这个平面直角坐标转换为经纬度，代码如下：

function positionToLatlng(x, y, z, radius = 1) {
  const [u, v] = unproject(x, y, z, radius);
  return projection.invert([u, v]);
}

接着，我们实现一个通过经纬度拿到国家信息的函数。这里，我们直接通过 d3.geoContains 方法，从 countries 数据中拿到对应的国家信息。

function getCountryInfo(latitude, longitude, countries) {
  if(!countries) return {index: -1};
  let idx = -1;
  countries.features.some((d, i) => {
    const ret = d3.geoContains(d, [longitude, latitude]);
    if(ret) idx = i;
    return ret;
  });
  const info = idx >= 0 ? {...countries.features[idx]} : {};
  info.index = idx;
  return info;
}

这样一来，我们只要修改 mousemove 方法，就可以知道我们的鼠标移动在哪个国家之上了。

globe.addEventListener('mousemove', (e) => {
  const [lng, lat] = positionToLatlng(...e.hit.localPoint);
  const country = getCountryInfo(lat, lng, countries);
  if(country.properties) {
    console.log(country.properties.name);
  }
});

高亮国家地区的方法

下一步，我们就可以实现一个方法来高亮鼠标移动到的国家或地区了。要高亮对应的国家或地区，其实处理起来并不复杂。我们先把原始的非高亮的图片另存一份，然后根据选中国家的 index 信息，从 contries 原始数据中取出对应的那个国家，用不同的填充色 fillStyle 再绘制一次，最后更新 texture 和 layer，就可以将高亮区域绘制出来了。代码如下：

function highlightMap(texture, info, countries) {
  if(texture.index === info.index) return;
  const canvas = texture.image;
  if(!canvas) return;

  const idx = info.index;
  const highlightMapContxt = canvas.getContext('2d');

  if(!imgCache) {
    imgCache = new OffscreenCanvas(canvas.width, canvas.height);
    imgCache.getContext('2d').drawImage(canvas, 0, 0);
  }
  highlightMapContxt.clearRect(0, 0, mapScale * mapWidth, mapScale * mapHeight);
  highlightMapContxt.drawImage(imgCache, 0, 0);

  if(idx > 0) {
    const path = d3.geoPath(projection).context(highlightMapContxt);
    highlightMapContxt.save();
    highlightMapContxt.fillStyle = '#fff';
    highlightMapContxt.beginPath();
    path({type: 'FeatureCollection', features: countries.features.slice(idx, idx + 1)});
    highlightMapContxt.fill();
    highlightMapContxt.restore();
  }
  texture.index = idx;
  texture.needsUpdate = true;
  layer.forceUpdate();

仔细看上面的代码你会发现，这里我们实际上做了两点优化，一是我们在 texture 对象上记录了上一次选中区域的 index。如果移动鼠标时，index 没发生变化，说明鼠标仍然在当前高亮的国家内，没有必要重绘。二是我们保存了原始非高亮图片。之所以这样做，是因为我们只要将保存的非高亮图片通过 drawImage 一次绘制，然后再绘制高亮区域，就可以完成地图高亮效果，而不需要每次都重新用 Path 来绘制整个地图了，因而大大减少了 Canvas2D 绘图指令的数量，显著提升了性能。

实现了这个函数之后，我们改写 mousemove 事件处理函数，就能将这个交互效果完整地显示出来了。具体的代码和效果图如下：

globe.addEventListener('mousemove', (e) => {
  const [lng, lat] = positionToLatlng(...e.hit.localPoint);
  const country = getCountryInfo(lat, lng, countries);
  highlightMap(texture, country, countries);
});

如何在地球上放置标记？

通过选中对应的国家，我们可以实现鼠标的移动的高亮交互效果。接下来，我们来实现另一个交互效果，在地球的指定经纬度处放置一些标记。

如何计算几何体摆放位置？

既然要把物体放置在地球指定的经纬坐标处，那我们接下来的操作依然离不开坐标转换。首先，我们知道几何体通常默认是以中心点为（0,0）点，但我们放置的时候，却需要将物体的底部放置在球面上，所以我们需要对球面坐标位置进行一个坐标变换。

因此，我们在实现放置函数的时候，会通过 latlngToPosition 先将经纬度转成球面坐标 pos，再延展到物体高度的一半，因为球心的坐标是 0,0，所以 pos 位置就是对应的三维向量，我们使用 scale 就可以直接将它移动到我们要的高度了。

function setGlobeTarget(globe, target, {latitude, longitude, transpose = false, ...attrs}) {
  const radius = globe.attributes.radius;
  if(transpose) target.transpose();
  if(latitude != null && longitude != null) {
    const scale = target.attributes.scaleY * (attrs.scale || 1.0);
    const height = target.attributes.height;
    const pos = latlngToPosition(latitude, longitude, radius);
    // 要将底部放置在地面上
    pos.scale(height * 0.5 * scale / radius + 1);
    attrs.pos = pos;
  }
  target.attr(attrs);
  const sp = new Vec3().copy(attrs.pos).scale(2);
  target.lookAt(sp);
  globe.append(target);
}

这里的 latlngToPosition 是前面 positionToLatlng 的反向操作，也就是先用 projection 函数将经纬度映射为地图上的直角坐标，然后用直角坐标转球面坐标的公式，将它转为球面坐标。具体的实现代码如下：

/**
 * 将经纬度转换为球面坐标
 * @param {*} latitude
 * @param {*} longitude
 * @param {*} radius
 */
function latlngToPosition(latitude, longitude, radius = 1) {
  const [u, v] = projection([longitude, latitude]);
  return project(u, v, radius);
}

/**
 * 将平面地图坐标转换为球面坐标
 * @param {*} u
 * @param {*} v
 * @param {*} radius
 */
function project(u, v, radius = 1) {
  u /= mapScale * mapWidth;
  v /= mapScale * mapHeight;
  const pLength = Math.PI * 2;
  const tLength = Math.PI;
  const x = -radius * Math.cos(u * pLength) * Math.sin(v * tLength);
  const y = radius * Math.cos(v * tLength);
  const z = radius * Math.sin(u * pLength) * Math.sin(v * tLength);
  return new Vec3(x, y, z);
}

有了这个位置之后，我们将物体放上去，并且让物体朝向球面的法线方向。这一步我们可以用 lookAt 函数来实现。不过，lookAt 函数是让物体的 z 轴朝向向量方向，而我们绘制的一些几何体，比如圆柱体，其实是要让 y 轴朝向向量方向，所以这种情况下，我们需要对几何体的顶点做一个转置操作，也就是将它的顶点向量的 x、y、z 的值轮换一下，让 x = y、y = z、 z = x。这么做之后，我们就可以在地球表面摆放几何体了。

摆放光柱

我们先在地球的指定位置上放置一些光柱，光柱通常可以用来标记当前位置是一个重要的地点。光柱效果如下图所示：

这个效果怎么实现呢？因为光柱本身是圆柱体，所以我们可以用 Cylindar 对象来绘制。而光柱的光线还会随着高度衰减，对应的 Shader 代码如下：

    const beamVertx = `
      precision highp float;
      precision highp int;

      attribute vec3 position;
      attribute vec3 normal;
      attribute vec4 color;

      uniform mat4 modelViewMatrix;
      uniform mat4 projectionMatrix;
      uniform mat3 normalMatrix;

      varying vec3 vNormal;
      varying vec4 vColor;

      uniform vec4 ambientColor; // 环境光
      uniform float uHeight;

      void main() {
        vNormal = normalize(normalMatrix * normal);
        vec3 ambient = ambientColor.rgb * color.rgb;// 计算环境光反射颜色
        float height = 0.5 - position.z / uHeight;
        vColor = vec4(ambient + 0.3 * sin(height), color.a * height);
        vec3 P = position;
        P.xy *= 2.0 - pow(height, 3.0);
        gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
      }
      `;

    const beamFrag = `
      precision highp float;
      precision highp int;

      varying vec3 vNormal;
      varying vec4 vColor;

      void main() {
        gl_FragColor = vColor;

实现 Shader 的代码并不复杂，在顶点着色器里，我们可以根据高度减少颜色的不透明度。另外，我们还可以根据高度对 xy 也就是圆柱的截面做一个扩展： P.xy *= 2.0 - pow(height, 3.0)，这样就能产生一种光线发散（顶部比底部略大）的效果了。

于是，对应的 addBeam 函数实现如下所示。它就是根据参数创建对应的圆柱体对象，并把它们添加到地球对应的经纬度位置上。

function addBeam(globe, {
  latitude,
  longitude,
  width = 1.0,
  height = 25.0,
  color = 'rgba(245,250,113, 0.5)',
  raycast = 'none',
  segments = 60} = {}) {
  const layer = globe.layer;
  const radius = globe.attributes.radius;
  if(layer) {
    const r = width / 2;
    const scale = radius * 0.015;
    const program = layer.createProgram({
      transparent: true,
      vertex: beamVertx,
      fragment: beamFrag,
      uniforms: {
        uHeight: {value: height},
      },
    });
    const beam = new Cylinder(program, {
      radiusTop: r,
      radiusBottom: r,
      radialSegments: segments,
      height,
      colors: color,
    });
    setGlobeTarget(globe, beam, {transpose: true, latitude, longitude, scale, raycast});
    return beam;
  }
}

摆放地标

除了摆放光柱，我们还可以摆放地标。地标通常表示当前位置产生了一个重大事件。地标实现起来会比光柱更复杂一些，它由一个定位点（Spot）和一个动态的标记（Marker）共同组成。摆放了地标的地图效果如下图所示：

想要实现它，第一步我们还是要实现对应的 Shader。不过，我们这次需要实现两组 Shader。首先是 spot 的顶点着色器和片元着色器，实现起来也非常简单。在顶点着色器中，我们根据 uWidth 扩展 x、y 坐标，根据顶点绘制出一个特定大小的平面图形。在片元着色器中，我们让图形的中心稍亮一些，让边缘亮度随着距离衰减，这么做是为了增强视觉效果。不过，由于分辨率的原因，具体的效果在截图中可能体现不出来，你可以运行示例代码把地球局部放大，来实际观察和体会一下。

const spotVertex = `
  precision highp float;
  precision highp int;

  attribute vec4 position;

  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;

  uniform float uWidth;
  uniform float uSpeed;
  uniform float uHeight;

  varying vec2 st;

  void main() {
    float s = 0.0 + (0.2 * uWidth * position.w);
    vec3 P = vec3(s * position.xy, 0.0);
    st = P.xy;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
  }
`;

const spotFragment = `
  precision highp float;
  precision highp int;

  uniform vec2 uResolution;
  uniform vec3 uColor;
  uniform float uWidth;

  varying vec2 st;

  void main() {
    float d = distance(st, vec2(0));
    gl_FragColor.rgb = uColor + 1.5 * (0.2 * uWidth - 2.0 * d);
    gl_FragColor.a = 1.

接着，我们实现 marker 的顶点着色器和片元着色器，它们稍微复杂一些。

const markerVertex = `
  precision highp float;
  precision highp int;

  attribute vec4 position;

  uniform mat4 modelViewMatrix;
  uniform mat4 projectionMatrix;
  uniform mat3 normalMatrix;

  uniform float uTime;
  uniform float uWidth;
  uniform float uSpeed;
  uniform float uHeight;

  varying float time;

  void main() {
    time = mod(uTime, 1.5 / uSpeed) * uSpeed + position.z - 1.0;
    float d = clamp(0.0, uWidth * mix(1.0, 0.5, min(1.0, uHeight)), time);
    float s = d + (0.1 * position.w);
    vec3 P = vec3(s * position.xy, uHeight * time);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
  }
`;

const markerFragment = `
  precision highp float;
  precision highp int;

  uniform vec2 uResolution;
  uniform vec3 uColor;

  varying float time;

  void main() {
    float t = clamp(0.0, 1.0, time);
    gl_FragColor.rgb = uColor;
    gl_FragColor.a = 1.0 - t

在顶点着色器里，我们根据时间参数 uTime 来调整物体定点的高度。这样，当我们设置 uHeight 参数时，marker 就能呈现出立体的效果。

有了这两组着色器之后，我们再实现两个函数，用来分别生成 spot 和 marker 的顶点，函数代码如下：

function makeSpotVerts(radis = 1.0, n_segments) {
  const vertex = [];
  for(let i = 0; i <= n_segments; i++) {
    const theta = Math.PI * 2 * i / n_segments;
    const x = radis * Math.cos(theta);
    const y = radis * Math.sin(theta);
    vertex.push(x, y, 1, 0, x, y, 1, 1.0);
  }
  return {
    position: {data: vertex, size: 4},
  };
}

function makeMarkerVerts(radis = 1.0, n_segments) {
  const vertex = [];
  for(let i = 0; i <= n_segments; i++) {
    const theta = Math.PI * 2 * i / n_segments;
    const x = radis * Math.cos(theta);
    const y = radis * Math.sin(theta);
    vertex.push(x, y, 1, 0, x, y, 1, 1.0);
  }
  const copied = [...vertex];
  vertex.push(...copied.map((v, i) => {
    return i % 4 === 2 ? 0.33 : v;
  }));
  vertex.push(...copied.map((v, i) => {
    return i % 4 === 2 ? 0.67 : v;
  }));
  return {
    position: {data: vertex, size: 4},
  };
}

这两个函数都是用生成正多边形顶点的算法来生成对应的顶点，它们的区别是，spot 只生成一组顶点，因为是平面图形，所以 z 坐标为 0，而 marker 则生成三组不同高度的顶点组成立体的形状。

接着，我们再实现一个初始化函数，用来生成 spot 和 marker 对应的 WebGLProgram，函数代码如下：

function initMarker(layer, globe, {width, height, speed, color, segments}) {
  const markerProgram = layer.createProgram({
    transparent: true,
    vertex: markerVertex,
    fragment: markerFragment,
    uniforms: {
      uTime: {value: 0},
      uColor: {value: new Color(color).slice(0, 3)},
      uWidth: {value: width},
      uSpeed: {value: speed},
      uHeight: {value: height},
    },
  });

  const markerGeometry = new Geometry(layer.gl, makeMarkerVerts(globe.attributes.radius, segments));

  const spotProgram = layer.createProgram({
    transparent: true,
    vertex: spotVertex,
    fragment: spotFragment,
    uniforms: {
      uTime: {value: 0},
      uColor: {value: new Color(color).slice(0, 3)},
      uWidth: {value: width},
      uSpeed: {value: speed},
      uHeight: {value: height},
    },
  });

  const spotGeometry = new Geometry(layer.gl, makeSpotVerts(globe.attributes.radius, segments));

  return {
    program: markerProgram,
    geometry: markerGeometry,
    spotGeometry,
    spotProgram,
    mode: 'TRIANGLE_STRIP',
  }

最后，我们实现 addMarker 方法，将地标添加到地球上。这样，我们就实现了绘制地标的功能。

function addMarker(globe, {
  latitude,
  longitude,
  width = 1.0,
  height = 0.0,
  speed = 1.0,
  color = 'rgb(245,250,113)',
  segments = 60,
  lifeTime = Infinity} = {}) {
  const layer = globe.layer;
  const radius = globe.attributes.radius;

  if(layer) {
    let mode = 'TRIANGLES';
    const ret = initMarker(layer, globe, {width, height, speed, color, segments});
    const markerProgram = ret.program;
    const markerGeometry = ret.geometry;
    const spotProgram = ret.spotProgram;
    const spotGeometry = ret.spotGeometry;
    mode = ret.mode;

    if(markerProgram) {
      const pos = latlngToPosition(latitude, longitude, radius);
      const marker = new Mesh3d(markerProgram, {model: markerGeometry, mode});
      const spot = new Mesh3d(spotProgram, {model: spotGeometry, mode});
      setGlobeTarget(globe, marker, {pos, scale: 0.05, raycast: 'none'});
      setGlobeTarget(globe, spot, {pos, scale: 0.05, raycast: 'none'});
      layer.bindTime(marker.program);

      if(Number.isFinite(lifeTime)) {
        setTimeout(() => {
          layer.unbindTime(marker.program);
          marker.dispose();
          spot.dispose();
          marker.program.remove();
          spot.program.remove();
        }, lifeTime);
      }
      return {marker, spot};
    }
  }

要点总结

今天，我们在上节课的基础上学习了与地球交互，以及在地球上放置标记的方法。

与地球交互的实现过程，我们可以总结为四步：首先我们通过将三维球面坐标转换为经纬度坐标，再通过 topoJSON 的 API 获取当前选中的国家或地区信息，然后在离屏 Canvas 上将当前选中的国家或地区高亮显示，最后更新纹理和重绘 layer。这样，高亮显示的国家或地区就出现在 3D 地球上了。

而要在地球上放置标记，我们先要计算几何体摆放位置，然后实现标记对应的 shader，创建 WebGLProgram，最后将标记添加到地球表面对应经纬度换算的球面坐标位置处，用 lookAt 让它朝向法线方向就可以了。

到这里，地球 3D 可视化的核心功能我们就全部实现了。实际上，除了这些功能以外，我们还可以添加一些更加复杂的标记，比如两个点之间连线以及动态的飞线。这些功能实现的基本原理其实和放置标记是一样的，所以只要你掌握了我们今天讲的思路，就能比较轻松地解决这些需求了。

小试牛刀

实际上，地球上不仅可以放置普通的单点标记，还可以用曲线将两个地理点连接起来。具体的方法是在两个点之间计算弧线或贝塞尔曲线，然后将这些连线生成并绘制出来。在 SpriteJS 的 3D 扩展中有一个 Path3d 对象，它可以绘制空间中的三维曲线。你能试着实现两点之间的连线吗？如果很轻松就能实现，你还可以试着添加动画，实现两点之间的飞线动画效果。（提示：你可以通过官方文档来学习 Path3d 的用法，实现两点之间的连线。在实现飞线动画效果的时候，你可以参照 GitHub 仓库里的代码来进行学习，来理解我是怎么做的）

欢迎把你实现的效果分享到留言区，我们一起交流。也欢迎你把这节课转发出去，我们下节课见！

源码

示例代码详见GitHub 仓库

推荐阅读

球坐标与直角坐标的转换

42 | 如何整理出我们自己的可视化工具集？

你好，我是月影。

我们知道，可视化虽然可以认为是前端的一个小分支，但总体来说它也是一个比较有广度的领域，从内容上大体包括数据图表、图形绘制、地理信息可视化、3D 场景和数字孪生应用等等，从设备上包括移动端、PC 浏览器和可视化大屏等等。

作为刚刚入门可视化领域的工程师，我们经常会因为知识面不够广、工具不够丰富，而寄望于用单一的工具来解决我们遇到的所有问题，所谓“手里有锤子，看什么都像是钉子”。想想也知道，这并不能很好地处理所有的可视化应用。因为，一旦我们选择了错误的、不适合于当前场景的工具，就会让最终实现的结果不理想，或者开发效率不高。

因此，想要成为一名优秀的可视化工程师，我们必须要知道如何丰富自己手中的工具集，来应对各种场景和挑战。丰富工具集，并不意味着我们需要从头开发一切适合各种场景使用的工具。大部分时候，我们可以利用行业发展和社区的力量，站在巨人的肩膀上，使用各种成熟的工具，来更好地完成我们的项目。

那今天，我带你一起系统地梳理目前行业里，适合可视化应用的优秀且成熟的工具，来丰富你的工具箱，让你在面对各种可视化需求时能够游刃有余。

首先，我们来看看可视化应用的主体需求分类，搞清楚了这些，我们才能选择出更合适的工具来完成这些需求。根据可视化场景，主体需求一般可以分成 5 种，分别是绘制基本图形、绘制基础图表、绘制关系图和流程图、绘制地理信息以及绘制三维模型和数字孪生应用。接下来，我就一一来说。

选择绘制基本图形的工具

我们先来说说绘制基本图形要选择的工具。简单情况下，我们用浏览器原生的四类基本图形系统就可以完成绘制，分别是 HTML/CSS、SVG、Canvas 和 WebGL。

HTML/CSS 比较简单，也有很多丰富的前端领域的工具，这里就不多说了。SVG 原生的 API 使用起来也并不复杂，不过我们也可以用一些成熟的绘图库，它能够让我们更方便地绘制各种图形。我比较推荐 Snap.svg 这个库，它提供的 API 能够非常方便地绘制各种 SVG 图形。尤其值得赞扬的是它提供了一套交互式教学文档，能够让你快速上手整个库的使用。

如果你使用 Canvas 和 WebGL 来绘制图形，可选的工具就比较多了。比较成熟的 2D 渲染引擎包括 Fabric.js、Pixi.js 等，比较成熟的 2D/3D 引擎包括 SpriteJS、P5.js 等，以及比较成熟的 3D 引擎包括 ThreeJS、Babylon.js 等。这些库各有特点，它们的官方使用文档和示例都比较完善，我建议你多花一些时间去学习它们的使用方法。这能帮助你在绘制基本图形的时候，灵活选用适合的库。

另外，既然说到了 Canvas2D 绘制图形，那我还要提一下Rough.js这个库。这是个非常有特点的库，它能够绘制出带有手绘风格的图形，比如，我们在第 5 课就使用过它。

我刚才说的这些工具，对于绘制基本的 2D/3D 图形来说已经足够了。但是，功能相对更强大和丰富的库，体积也会更大一些，使用上也会复杂一些，所以我们还是应该根据实际需求复杂程度来灵活选择。

选择绘制基础图表的工具

绘制基础图表是可视化项目中很常见的需求，我们可以采用图表库和数据驱动框架来完成。常用的成熟图表库包括 ECharts、Chart.js、Highcharts、AntV G2 这 4 种。大部分图表库在使用上大同小异，效果也差别不大，好像选什么都没有差别。但在选择图表库的时候，我们也需要考虑底层的图形系统或图形库，这关系到复杂图表渲染和交互的性能，另外在同时需要绘制图表和基本图形的时候，选择统一的图形系统可以保持一致性，也能更好地实现图表与图形的协同交互。

这里，我就说几个常见的搭配组合。如果你使用 SpriteJS 作为底层图形库，还可以选择QCharts。如果你是在移动端设备渲染图表，可以考虑使用AntV F2，这是一个专为移动端场景设计的图表库。如果你要绘制更加灵活的图表，以及关系图和流程图，可以选择数据驱动框架，例如D3.js。不过 D3.js 虽然很好用，但它是一个足够复杂的框架，如果你希望在可视化领域深入发展，最好能再多花一些时间，彻底掌握它的使用方法。

最后，我还想再说说Vega这个库。这也是一个图表库，它定义了一套基于 JSON 规范的可视化语法，以声明式的方式来绘制各种图表。最关键的是，Vega 定义可视化语法规范的思路，对我们自己设计和实现图表库有着非常大的借鉴意义。如果你打算自己设计一套图表库，我希望你能好好研究一下 Vega，相信你会有所收获的。

选择绘制关系图和流程图的工具

接着，我们再来说一类特殊的图表，比如关系图、流程图、脑图等等。我们一般将它们单独归为一类应用，称为图可视化。

图可视化怎么实现呢？我们在第 38 课里使用 D3.js 实现过相关的例子。除了 D3.js 以外，还有一类直接绘制这些图形的图可视化库，常用的有Mermaid.js、Sigma.js以及AntV G6等等。

其中 Mermaid.js 量级更轻，主要是以声明的方式来绘制各种流程图。而 Sigma.js 和 AntV G6 的功能更丰富，实现的图可视化不仅类型更多，还能包含复杂的用户交互效果。

此外，还有一个特殊的库Dagre。它是绘制流程图的底层库，主要是用来计算图的元素布局，使用它再结合图形库，我们就能自己实现一个绘制流程图的图可视化库。这个图可视化库实现起来也不是很难，我把这个任务留在本节课的末尾，你可以试着去挑战一下。

选择地理信息可视化工具

前面两节课，我们一起完成了 3D 地理信息可视化的实战。地理信息可视化是可视化项目中很重要的一类应用，大部分可视化大屏应用场景都会包含地图或者 3D 地球，因为许多数据本就和地理信息相关。由于需求很多，因此市场上有许多地理信息可视化的库可供我们选择。

比较成熟的地理信息可视化库包括MapBox、MapTalks、Leaflet.js、MapV、AntV L7等等，它们都支持简单的 geoJSON、topoJSON 数据和分片加载的瓦片地图。另外 d3 的子模块d3-geo也能够处理地理信息可视化，尤其是它提供了多种地图投影，非常适合与其他库联动，实现各种不同的地图场景应用。

总体而言，地理信息可视化是可视化领域里比较复杂的方向，这些地理信息可视化库的功能也较为丰富，使用场景很多，如果详细来讲的话，内容足够支撑起一门单独的课程了。我们前面花了 3 节课来讲的地理信息可视化案例，其实也只是给地理信息可视化应用开了一个头，如果你有兴趣深入学习，可以通过这些地理信息可视化库，再结合实战来真正深入掌握地理信息可视化的方法，这也是成为优秀可视化工程师的必经之路。

处理三维模型和数字孪生应用

在学习 3D 图形的绘制的时候，我讲到，3D 绘制一般有两种方式，一种是加载静态的 3D 模型数据，然后将 3D 物体渲染出来。这些 3D 模型数据，通常是通过设计工具离线生成的。这种应用场景在游戏领域比较常见。

SpriteJS加载的3D模型数据，图片来源：spritejs.org

另一类则是动态加载 3D 几何模型，用前面绘制基本图形的工具就可以实现。在可视化应用中，这类场景通常更普遍。

动态加载的几何图形，图片来源：OGL

不过，在可视化领域中，有一类应用也会用到非常多的 3D 模型，那就是数字孪生应用。所谓数字孪生，是对物理世界实体或系统的数字化表达。简单来说，就是在虚拟世界中，通过 3D 渲染来还原真实物理世界，这需要我们将现实世界中的物体模型化为虚拟世界中的 3D 几何体。

物理世界和数字孪生示意图，图片来源：36kr.com

在这样的应用场景中，有时候我们可以考虑采用游戏的方式，使用游戏引擎和框架，例如Unity或者虚幻引擎来完成我们的可视化应用。当然，这也就进入了另一个领域，游戏创作的领域。这部分内容，我们简单了解一下就可以了。

要点总结

可视化有着丰富的使用场景，大致上可以分为 5 类，分别是：绘制基本图形、绘制基础图表、图可视化、地理信息可视化以及数字孪生。

这些场景各自有适合的工具库和框架可供我们选择，我在这里整理了一个脑图，供你在具体应用中参考。

这是我们专栏最后一个模块的最后一节课了，在这几个月的时间里，我们先后学习了图形学与数学、视觉基础、视觉高级以及数据处理的相关知识。其中，我们通过一些可视化实战，学习了部分图形系统与图形库和工具的使用，这当中主要包括 SpriteJS、D3.js、QCharts 等等。这和我们这节课列出的各类工具相比，可以说是非常少了。

我想有的同学可能会有疑问，还有这么多的工具我们并没有详细来讲，那我学习到这里，算是真正入门可视化了吗？我想说的是，工具和库都是为我们服务的，正确使用这些工具和库的基础，其实就是我们前面学过的图形学与数学基础、视觉呈现技术，以及数据处理的原则，这些内容才是可视化的本质。而上层的工具和库的使用虽然复杂，但并不难，基本上照着文档学习和实践，就能够一步步掌握。

而如果缺少扎实的基础，那么在使用工具遇到缺失功能或者性能问题的时候，你就可能会束手无策。因此，这门课，更多的还是为你打好基础，领你进入可视化的世界，今后的路，就要靠你自己一步一步地走了。

小试牛刀

今天，我特意提到了 Dagre 这个库。它是一个基础库，经常用来给流程图的元素布局，不涉及渲染的部分。你能用它作为基础，再以 SpriteJS 为渲染引擎，来实现一个高效、强大的流程图库吗（提示：在 API 方面你可以借鉴 Mermaid.js）？

除此之外，这节课，我们介绍的工具和库非常多，你肯定很难在短时间内完全掌握，但我也不希望你只是泛泛了解。所以，我希望你能选择自己最感兴趣的同类库中的两个库，利用我之前在 GitHub 仓库里留的北京天气和空气质量数据，分别使用它们做一些可视化应用，比较两个库在使用上的不同，以及各自的优、劣势。

欢迎把你实现的效果分享到留言区，也欢迎你把自己在工具整理上的心得体会分享出来，我们一起交流！

推荐阅读

[1] Snap.svg、[2] Fabric.js、[3] Pixi.js

[4] SpriteJS、[5] P5.js、[6] ThreeJS

[7] Babylon.js、[8] Rough.js、[9] ECharts

[10] Chart.js、[11] Highcharts、[12] AntV G2

[13] QCharts、[14] AntV F2、[15] Vega

[16] Mermaid.js、[17] Sigma.js、[18] AntV G6

[19] Dagre、[20] MapBox、[21] MapTalks

[22] Leafletjs、[23] MapV、[24] AntV L7

[25] d3-geo、[26] Unity、[27] Unreal Engine

加餐一 | 作为一名程序员，数学到底要多好？

你好，我是月影。

刚刚学完了可视化的数学篇，今天咱们放松一下，以我的个人经历来聊一聊，数学对我们程序员的重要性。

作为奇舞团团长和从事前端 15 年以上的“老人”，我为团队面试过许多同学，也和许多同学聊过前端或者程序员的职业发展方向。一般来说，我面试的时候会要求面试者有一定的数学基础，在聊关于职业发展的话题时，我也会强调数学对于程序员成长的重要性。甚至，在可视化这门课里面，我也认为学习可视化的第一步是学好图形学相关的数学知识。

不过，行业里也有些不同的声音，有些人觉得除了部分特殊的领域，大部分普通的编程领域不太需要数学知识。比如说，在我们平时的 Web 开发项目中，不论是前端还是后端，似乎更多地是和产品与业务逻辑打交道，比较少或几乎没有用到数学知识。甚至有些人认为，程序员根本用不着学好数学，特别是在前端这样偏 UI 层的领域，数学更是没有用武之地。

当然，以上这些认为数学不重要的想法，我都可以理解，曾经我自己也没有意识到数学和编程有什么必然的联系。而且，我当年在学校学习的时候，数学也学得很马虎，基础也不是那么好。不过后来，我个人的一段经历，让我很早就意识到数学对编程的重要性，而这个认知，对我后来的职业发展有着非常重要的影响。所以，我想在这里和你分享一些我个人成长中的经历和收获，希望能对你有些帮助。

实习面试的两个问题

2003 年，因为朋友的推荐，我获得了微软亚洲研究院（MSRA）访问学生的面试机会，当时的面试官是浙江大学的刘利刚博士，他也是我后来的实习导师。那时，他正在 MSRA 做访问学者。

在这之前，我没有任何面试经验，在学校里面，我的学习成绩也一般，只是对编程比较感兴趣，自己做过一些小项目。我不知道会被面试什么问题，所以也没特意准备。见到了利刚博士之后，他并没有问我任何有关编程的问题，而是问了我两个数学问题，这两个问题让我至今仍记忆犹新。

第一个问题是这样的：

已知 ABC 是三个不同的数字，且能使以下等式成立，求 A、B、C 分别是多少。

求这道题的答案并不是很难，但是花多久的时间能得出答案却是一个问题。我当时回答出这个问题，大概只用了不到 10 秒钟。你可以先试着解一下，看看你能在 10 秒内给出这个问题的答案吗？

其实，利刚博士出这道题，主要在考察我的数感。啥是数感呢？这不是指一个人具备了多么高深的数学知识，而是指他对数字的一种直觉以及洞察力。

我在解决这个问题的时候，完全是脱口而出答案，我甚至都没有意识到自己是怎么得出来的。但是，当我一下说出答案之后，再回想为什么才反应过来，这道题其实是有规律的。

你仔细看中间这一列，应该能一下子得出 A 的值是 9。然后再看第一列，就能得到 C 的值是 4，最后 B 的值自然就是 5 了。所以答案就是 A = 9、B = 5、C = 4。

那面试为啥要考察数感呢？利刚博士是这么给我解释的：数感好表示学习和理解能力强，因为访问学生要做的工作内容就是图形学的基础研究，一些知识肯定是要现学的，这需要有比较强的学习能力和理解力，所以作为数学基础的数感就很重要了。正是通过这个问题，我认识到了数感的重要性。

好了，接下来我们接着来看第二个问题。

给你一个天平和一个物体，让你设计一些砝码，无论这个物体的重量是在 1～100 克之间的任何一个整数克数，都能用这些砝码称量出来，并且砝码的数量要尽可能少，你最少需要几个砝码呢？

乍一看，这个问题似乎与计算机和编程完全不沾边，但实际上这个问题涉及基础的数的进制原理。为什么说这个天平称重涉及数的进制原理呢？

因为我们知道，天平一般来说标准的用法是左边托盘放物体，右边托盘放砝码。那如果我们要称重的物体在 1～100 克之间，还要设计尽可能少的砝码，最优解肯定是称某个克数的砝码组合是唯一的，这样最省砝码。

怎么理解呢？我举个例子。

假如说，现在我们有 3 个砝码，分别为 A 砝码 1 克、B 砝码 2 克、C 砝码也是 2 克，显然这 3 个砝码可以称 1～5 克的物体。如果物体是 1 克的话，那么用 A 砝码就行；如果物体是 2 克的话，有两种方法，用 B 砝码，或者用 C 砝码；如果物体是 3 克的话，也有两种方法，用 A+B 或 A+C 砝码；如果物体是 4 克的话，用 B+C 砝码，如果物体是 5 克的话，用 A+B+C 砝码。

但是我们看到，在物体是 2 克和 3 克的时候，分别有两种砝码组合对应的称量方法。如果我们把一种砝码组合作为一种编码，再把一种物体克数作为一个状态的话，那么重复的编码就只表示同一种状态，这就属于浪费。显然更好的解决办法，是用最少的编码组合表示尽可能多的状态。甚至我们应该做到一种编码唯一对应一种状态，这样才是最优的。

所以呢，我们应该把 C 砝码改为 4 克，这样一来，3 个砝码就可以称出 1～7 克的物体，而且没有任何两种编码表示同一种状态，这就是最优的。我把具体的称量方法总结出一张表，列在了下面。

看到这里，聪明的同学应该已经知道这一题的答案了。实际上，我们将砝码被使用记为 1，将砝码不被使用记为 0，那这个问题就等价于：用多少位二进制数可以表示不大于 100 的正整数？因此，答案自然是 7 位，也就是说砝码需要 7 个，重量分别是 1 克、2 克、4 克、8 克、16 克、32 克和 64 克。

所以你看，这个问题表面上是天平问题，实际上牵扯到数的进制表示，或者说是编码，这显然是一个计算机问题。这其实也是利刚博士问我这个问题的真正目的，它同时考查了我关于数学模型的抽象能力，以及对计算机基础知识的理解程度。

顺便再说一下，这个问题如果允许将砝码放在天平左侧托盘中，那么有一个技巧可以让用到的砝码数量更少，你能想到该怎么做吗？如果你想到了，可以在留言里分享你的答案。

我的图形学实习经历

回答出这两个问题之后，我通过了面试，来到微软亚洲研究院实习。我的课题是图形学基础研究，恰好是和三角剖分有关，具体来说是简单多边形的相容三角剖分。

什么是简单多边形的相容三角剖分呢？简单来说，就是将两个简单多边形剖分成同样数量的三角形，同时还需要保证每个三角形的顶点能够一一对应。所谓一一对应，就是给两个多边形的顶点进行编号之后，它们中每一对三角形顶点的编号都相同。

如果两个简单多边形的边数相同，在不允许添加内部点的情况下，并不总能构成相容三角剖分。比如下图中的两个六边形，左边三条虚线构成三角形，而右边的三条虚线却相交于一个点，所以对这两个图形来说，如果我们不添加内部点，就不存在相容三角剖分了。

如果我们允许在图形内部添加点进行三角剖分，就可以得到相容三角剖分了，剖分后的效果如下图：

而我当时的工作主要是研究如何对多边形进行快速相容三角剖分。之所以研究相容三角剖分，是因为通过相容三角剖分可以生成拓扑结构相同的三角网格，而拓扑结构相同的三角网格是实现物体变形特效的基础。比如说，我们可以用相容三角剖分实现人物的“变脸”特效等等。

大体上，我的研究就是围绕相容三角剖分涉及的算法，因为它们都比较复杂，这里我就不多说了。接下来，我主要介绍一下我的工作中遇到的问题。因为涉及图形学的基础内容，自然会有一些基础的图形学计算，比如计算点到直线和线段的距离，计算边的切线和法线，判断线段的关系，绘制圆锥曲线等等。

我实习的第一个任务

在我刚开始实习的时候，第一个任务就是要计算点到直线和线段的距离。

不过，一开始我在求点到直线的距离的时候，是先写出直线的两点式代数方程，然后求点与直线的垂线方程，接着将两个方程联立求交点，最后再求出点与交点的距离。

这么做当然是可以求出结果来的，但是计算过程可以说是相当繁琐了，而且它还有缺陷。缺陷究竟是什么呢？我们知道，用直线方程求垂线的时候，要用到点斜式，但是点斜式的斜率，在直线垂直于 X 轴时，会有斜率计算出来是无穷大的问题。这种特殊情况还需要特殊处理，就更增加了我计算的复杂度。

所以，当时我花了一天时间才把“求点到直线和线段距离的问题”用代数方程解决。但是，第二天给利刚博士交差的时候，就被他批评了一顿，他问我为什么要用代数方程去做这个问题，如果用向量来做，根本就是分分钟的事儿。

如果你认真学了数学篇的课程，应该也已经知道，用向量解决这个问题的确非常简单。因为向量叉积的几何意义就是平行四边形的面积，在用向量叉积除以底边就是高，也就是点到向量所在直线的距离了。而我当时并没有想起可以用向量来解决这类问题，所以才走了弯路。

经过这次教训，我深刻意识到选择正确数学工具，能够把看似非常复杂的问题转化为简简单单问题，从而顺利解决。这也是为什么数学对于程序员来说非常重要。这个教训也是我在 MSRA 实习中最重要的收获。

两次数学实践

大约 4 个多月后，我就结束了 MSRA 的实习，回到了学校。毕业后，我去了一家深圳的软件公司，真正地成为了一名程序员。后来更是在机缘巧合下接触到了前端，也一直成长到今天。

在成长的过程中，我始终牢记：用数学思想和意识去解决工作中的问题。你别说，还真被我遇到了两个事儿。接下来，我就和你说说我印象最深刻的这两个案例。

第一个案例是我在 08 年到百度时遇到的。当时，某个产品中有一个绘制椭圆的需求，负责开发的工程师是使用椭圆的代数方程来计算的。因为代数方程涉及开平方的问题，所以开发人员还要根据象限来判断正负号，这会非常麻烦。

椭圆代数方程涉及开根号

现在你学习了数学篇的课程，应该知道用椭圆的参数方程来解决，根本不会涉及开根号和象限判断问题，操作起来也会简单很多。

椭圆的参数方程

另一个案例，是我在 360 搜索的一个运营活动中遇到的。当时需要我们实现一个 Canvas2D 的图片特效，效果如下：

具体要求是在一张静态图片上，选择若干个运动区域，比如示意图中的猫的耳朵、眼睛、鼻子区域，然后对区域做这样逆时针旋转的运动，最终将它变为动图。

这其实是一个 Canvas2d 的特效。最初的时候，我们的工程师是使用代数方法来解决的，具体的方法如下：

代数方法虽然能解决问题，但是会有 4 个缺陷：

求角需要计算反正切，性能差；

计算中需要开平方，要判断符号；

求反正切有无穷大问题，需要特殊处理；

有重复的计算量，进一步消耗性能。

不过，在我采用了向量法进行优化之后，这个动图的性能提升了数倍。具体算法如下图：

这两个案例其实共同说明一个道理：学会选择合适的数学工具，才能用最优的方式轻松解决问题。

小结

以上就是我和数学相关的个人经历了。总的来说，我收获到最重要的经验就是，要学会选择合适的数学工具来解决问题。当你选对工具之后，那些看似复杂的问题，可能会变得无比简单，从而被迅速解决。所以我们要重视数学基础的积累，锻炼自己的数感，拓宽知识面，以数学思维来思考计算机问题。

当然，我也不是说，程序员必须要有多么高深的数学知识。你看我们专栏中需要的数学知识，基本上就是一些高中数学知识和一部分基础的线性代数知识。但是数感、数学思维锻炼还是很重要的，这些锻炼越多，程序员的逻辑能力、抽象能力也会得到提高。更重要的是，通过锻炼我们能形成用数学思维思考问题的习惯，这样才能迅速找到最合适解决某类问题的数学工具，从而提升我们的技术能力。

小试牛刀

最后我再留两道思考题，来锻炼一下你的数感和数学思维能力。

我们知道简单多边形和复杂多边形区别是，是否有非相邻的边相交。那为了判断一个多边形是不是简单多边形，我们可以实现一个函数，来判断两个线段是否相交。你能实现这个函数吗？

左侧ab、cd线段相交，右侧ab、cd线段不相交

假如你手里有 5 个硬币，已知随机抛一次之后，有的硬币正面朝上，有的硬币反面朝上。请问：随机抛一次，有 3 个或 3 个以上硬币正面朝上的概率是多少？

欢迎你在留言说说数学对你的影响，也欢迎你和我分享关于数学方面的疑惑，我们一起探讨。

加餐三 | 轻松一刻：我想和你聊聊前端的未来

你好，我是月影。今天咱们来聊一个轻松点的话题。

我做前端工程师，也有 15 年了。常常听到有前端开发“抱怨”，“别更新了，学不动了”，也会有人经常问我，Deno、TypeScript 等新轮子层出不穷，未来前端重点方向在哪？还有，在大前端浪潮下，前端开发该如何持续学习、成长？所以今天，我想和你围绕这些话题来聊一聊。

别更新了，学不动了？

我曾经听一位前端技术专家说过，“前端十八个月难度翻一番”，这句话真的说出了前端领域更新换代之快背后的前端开发血泪史。也因此，“别更新了，学不动了”这句话成为了不少前端开发玩梗的口头禅。

但是对我来说，技术发展得越多、越快我就越兴奋。我非常喜欢研究技术，尝试新东西，不怕学习，也更没有学不动这种感觉。我一直觉得，如果一个行业的新东西层出不穷，说明这个行业一直在高速发展，这本身对于从业者来说是一个非常好的事情，因为这说明这个行业中有更多的机会和成长空间。

不过，一些前端开发对技术更新的担忧，我也能够理解。我的建议是，如果你不盲目地去追求所谓的“时髦”技术，不去刻意担心自己是否落伍，而是去多观察这个行业，找到技术发展内在的规律和脉络，那么你就知道该怎么前进，不会有任何恐慌了。

在任何一个领域或方向，知识体系都可以分为基础知识和领域知识，而领域知识又可以分为通用领域知识和专用领域知识。它们之间的变化是不一样的，基础知识的变化最慢，其次是通用领域知识，然后是专用领域知识。

用可视化这个方向来举例，基础知识是数学和图形学知识，比如向量、矩阵运算、三角剖分这些知识属于基础知识，它们基本上不会随着时间发生很大变化。而 JavaScript、WebGL 这种属于通用领域知识，它们会改变，也会慢慢发展，比如从 WebGL1.0 发展到 WebGL2.0，从 ES2019 发展到 ES2020，但不会变化、发展得那么快。而类似 ThreeJS、BabylonJS、SpriteJS 和 D3.js 这些属于专用领域知识，很有可能一个大版本升级就会有很大的变化。

学习这些知识，也有不同的方法。一般来说，如果是基础知识，随便什么时候我们都可以学，而且越早学习越好。基础知识就像是你的内功，学好它们，融会贯通之后，学习其他的知识都是事半功倍的。如果是通用的领域知识，一旦你下决心从事这个领域，也是能够尽早学习它比较好，不过由于这些知识是领域相关的，如果能一边学习，一边通过实践来打磨就会掌握得更快。专用领域知识，不一定要很早去学，有一个技巧是，当你用到的时候再去学习它们。如果你没有用到，你可以知道有这门技术，能做什么就行了，不用花大量时间和精力去钻研它们。

如果你觉得技术更新太快，学不过来，很可能就是被这些专用领域知识给“迷惑”了。比如，我听人说前端工程化里的代码打包很重要，于是今天学习了 webpack，明天又去学习 rollup。可实际上这种专用领域知识，我们只需要知道它们能做什么，在用到的时候再去详细学习就好了。

如何看待 Deno、TS 和未来的前端重点方向？

好，解决了第一个问题，我们再来说说 Deno、TS 和未来前端的重点方向。

最近几年流行的编程语言很多都号称是 JavaScript 的替代语言，比如 TypeScript。前端三大框架现在也基本都增加了对 TypeScript 的支持，这背后的本质原因是什么呢？

我认为，近几年 JavaScript 的语言标准发展很快，这背后依托的依然是 Web 应用领域的高速发展，JavaScript 是 Web 领域事实上的“原生语言”和技术标准，很多编程语言都是 JavaScript 的衍生语言。TypeScript 就是其中之一，它是一个很优秀的编程语言，其静态类型对一些规模较大的项目提高代码的可维护性很有帮助，因此现在写 TypeScript 的开发者越来越多，三大框架增加对其支持是顺其自然的事。

Deno 最近也发布了正式的 1.0 版本。我认为它是一个很好的 Runtime，在 Node.js 之后走了另外一条道路，规避了 Node.js 设计上的不足之处。不过，未来 Deno 不见得会取代 Node，它们两个很有可能会一直共同发展下去。但是 Deno 的设计本身就是建立在对 Node 的思考和改进之上的，所以我们学习它，对理解 Node.js 的精髓也非常有帮助。不过，我也只对 Deno 有简单的了解，也希望之后有机会可以去深入地学习和使用它。

因为我自己这两年的主要精力放在可视化领域，主要是可视化渲染方面，所以我觉得可视化是非常值得前端工程师重视的一个领域。随着 Web 技术的发展，视觉特别是 WebGL/GPU 相关的应用场景会越来越丰富，对技术要求也会越来越高。与前端其他大部分技术不同，WebGL 的上手门槛比较高，需要对数学、图形学有比较扎实的基础，而图形学和视觉呈现技术本身的天花板非常高，未来这块一定会有非常大的发展空间。

另外，AI 以及 VR/AR 也是未来前端的发展方向。对于 VR/AR，主流浏览器也开始支持 webXR 技术，而且无论 AI 还是 XR 这些领域，其实也和 GPU 息息相关，所以它们和可视化技术也是有关联的。

除此之外，还有一些跨端技术，从 RN 到 Flutter，经过了很多的发展，但还不是很成熟，而跨端本来就有很多应用场景，未来依然有很大的成长空间。PC 端的 Electron 也不容忽视，作为跨平台应用开发，它是一个非常好用的工具。

最后是一些非常新的技术，比如 Web Assembly、JS Binding，它们是一些跨界交叉领域发展来的前沿的技术，同样也值得我们持续关注。

给前端开发的一些真诚建议

最后，我想从前端工程师以及技术管理的角度，总结一些我自己的经验分享给你。

首先，你要确定自己是不是真正喜欢和热爱前端开发这个职业。当然我相信，大多数同学成为前端工程师，是因为内心真正喜欢这个职业。但是，之前我也听到有些同学说，因为觉得在程序员中前端比较“简单”，或者觉得自己数学或算法基础不好，做前端对这些要求不高，再或者就是觉得前端工程师算是份体面的职业，所以才选择它，其实内心并没不是真的热爱这个职业。

如果你仅仅把它当作一份谋生工作的话，那么你可能在这个职业道路上也走不了多远，肯定也无法达到很高的高度。所以我建议你反思一下，自己是否真的适合前端开发这个职业。

如果你确实热爱这个职业，正在考虑长远发展，我建议你最好选择一个好的平台，一个技术氛围好的团队，一份节奏合适的工作。我说的节奏合适指的是忙闲交替，既不会长时间特别忙，也不会持续特别闲。在这样的节奏下，项目积累再加上自己的学习沉淀，你就可以快速地成长了，而且技术氛围好的团队，也可以加快你学习沉淀的速度。

想在专业上达到一定的高度，因为每个人的情况不一样，所以我们要根据自己的情况来规划。不过总还是能找到一些共通点的，我觉得有一点很合适：找到并突破前端领域的“边界”。这个边界可以是某些有深度领域的技术前沿，也可以是某个交叉领域，如与服务端的交界，与移动客户端的交界等等。如果我们能在这些边界上做出突破，就肯定可以步入前端专家的行列了。

其实前端专家除了需要技术能力以外，还需要有意识地打造自己的个人影响力，锻炼自己的领导力，要让自己心态开放、眼界开阔，不排斥新技术，拥抱开源，多参与社区。

总之一句话，想要在职业之路上达一个比较高的高度，软实力和硬实力我们要两手抓。

今天说了这么多，其实希望如果你真的下定决心在前端这条路上钻研下去，一定别忘了，方向和努力缺一不可。最后，希望正在看这节课的你，可以成为未来优秀的前端专家，我们一起让前端行业变得更好。

今天的分享有没有解决你的疑惑呢？快把这节课分享给你的朋友吧！今天的内容就到这里了，我们下节课见！

加餐二 | SpriteJS：我是如何设计一个可视化图形渲染引擎的？

你好，我是月影。

今天，我们来聊一个相对轻松的话题，它不会有太多的代码，也不会有什么必须要掌握的理论知识。不过这个话题对你理解可视化，了解渲染引擎也是有帮助的。因为我今天要聊的话题是 SpriteJS，这个我亲自设计和实现的图形渲染引擎的版本迭代和演进。

SpriteJS 是从 2017 年下半年开始设计的，到今天已经快三年了，它的大版本也从 1.0 升级到了 3.0。那么它为什么会被设计出来？它有什么特点？1.0、2.0、3.0 版本之间有什么区别，未来会不会有 4.0 甚至 5.0？别着急，听我一一道来。

SpriteJS v1.x （2017 年~2018 年）

我们把时间调回到 2017 年下半年，当时我还在 360 奇舞团。奇舞团是 360 技术中台的前端团队，主要负责 Web 开发，包括 PC 端和移动端的产品的前端开发，比较少涉及可视化的内容。不过，虽然团队以支持传统 Web 开发为主，但是也支持过一部分可视化项目，比如一些 toB 系统的后台图表展现。那个时候，我们团队正要开始尝试探索可视化的方向。

如果你读过专栏的预习篇，你应该知道，要实现可视化图表，我们用图表库或者数据驱动框架都能够实现，前者使用起来简单，而后者更加灵活。当时，奇舞团的小伙伴更多是使用数据驱动框架D3.js来实现可视化图表的。

对 D3.js 来说，D3-selection是其核心子模块之一，它可以用来操作 DOM 树，返回选中的 DOM 元素集合。这个操作非常有用，因为它让我们可以像使用 jQuery 那样，快速遍历 DOM 元素，并且它通过 data 映射将数据与 DOM 元素对应起来。这样，我们用很简单的代码就能实现想要的可视化效果了。

比如，我们通过 d3.select('body').selectAll('div').dataset(data).enter().append('div')，把对应的 div 元素根据数据的数量添加到页面上的 body 元素下，然后，我们直接通过.style 来操作对应添加的 div 元素，修改它的样式，就能轻松绘制出一个简单的柱状图效果了。

    const dataset = [125, 121, 127, 193, 309];
    const colors = ['#fe645b', '#feb050', '#c2af87', '#81b848', '#55abf8'];

    const chart = d3.select('body')
      .selectAll('div')
      .data(dataset)
      .enter()
      .append('div')
      .style('left', '450px')
      .style('top', (d, i) => {
        return `${200 + i * 45}px`;
      })
      .style('width', d => `${d}px`)
      .style('height', '40px')
      .style('background', (d, i) => colors[i]);

这是一个非常快速且方便的绘图方式，但它也有局限性。D3-selection 只能操作具有 DOM 结构的图形系统，也就是 HTML 和 SVG。而对于 Canvas 和 WebGL，我们就没有办法像上面一样，直接遍历元素并且将数据和元素结构对应起来。

正因为 D3-selection 操作 DOM 使用起来特别方便，所以常见的 D3 例子都是用 HTML 或者 SVG 来写的，很少使用 Canvas 和 WebGL，即便后两者的性能要大大优于 HTML 和 SVG。因此，当时实现 SpriteJS 1.0 的初衷非常简单，那就是我希望让团队的同学既能使用熟悉的 D3.js 来支持可视化图表的展现，又可以使用 Canvas 来代替默认的 SVG 进行渲染，从而达到更好的性能。

所以，SpriteJS 1.0 实现了整个 DOM 底层的 API，我们可以像操作浏览器原生的 DOM 一样来操作 SpriteJS 元素，而我们最终渲染出的图形是调用底层 Canvas 的 API 绘制到画布上的。这样一来，SpriteJS 和 HTML 或者 SVG，就都可以用 D3-selection 来操作了，在使用上它们没有特别大的差别，但 SpriteJS 的最终渲染还是通过 Canvas 绘制的，性能相比其他两种有了较大的提升。

比如说，我用 D3.js 配合 SpriteJS 实现的柱状图代码，与使用 HTML 绘制的代码区别不大，但是由于是绘制在 Canvas 上，性能会提升很多。

    const {Scene, Sprite} = spritejs;
    const container = document.getElementById('container');
    const scene = new Scene({
      container,
      width: 800,
      height: 800,
    });

    const dataset = [125, 121, 127, 193, 309];
    const colors = ['#fe645b', '#feb050', '#c2af87', '#81b848', '#55abf8'];

    const fglayer = scene.layer('fglayer');
    const chart = d3.select(fglayer)
      .selectAll('sprite')
      .data(dataset)
      .enter()
      .append('sprite')
      .attr('x', 450)
      .attr('y', (d, i) => {
        return 200 + i * 45;
      })
      .attr('width', d => d)
      .attr('height', 40)
      .attr('bgcolor', (d, i) => colors[i]);

除了解决 API 的问题，以及让 D3-selection 可以使用之外，为了让使用方式尽可能接近于原生的 DOM，我还让 SpriteJS 1.0 实现了这 4 个特性，分别是标准的 DOM 元素盒模型、标准的 DOM 事件、Web Animation API （动画）以及缓存策略。

盒模型、DOM 事件和 Web Animation API ，我想你作为前端工程师肯定都知道，所以我多说一下缓存策略。还记得在性能篇里我们说过，要提升 Canvas 的渲染性能，就要尽量减少绘图指令的数量和执行时间，比较有效的方式是，我们可以将绘制的图形用离屏 Canvas 缓存下来。这样，在下次绘制的时候，我们就可以将缓存未失效的元素从缓存中用 drawImage 的方式直接绘制出来，而不用重新执行绘制元素的绘图指令，也就大大提升了性能。

因此，在 SpriteJS 1.0 中，我实现了一套自动的缓存策略，它会根据代码运行判断是否对一个元素启用缓存，如果是，就尽可能地启用缓存，让渲染性能达到比较好的水平。

SpriteJS 1.0 实现的这些特性，基本上满足了我们当时的需要，让我们团队可以用 D3.js 配合 SpriteJS 来实现各种可视化图表项目需求，而且使用上非常接近于操作原生的 DOM，非常容易上手。

SpriteJS v2.x （2018 年~2019 年）

到了 2018 年底，我开始思考 SpriteJS 的下一个版本。当时我们解决了在 PC 和移动 Web 上绘制可视化图表的诉求，不过外部的使用者和我们自己，在一些使用场景中，逐渐开始有一些跨平台的需求，比如在服务端渲染，或者在小程序中渲染。

因此，我开始重构代码，将绘图系统分层设计，实现了渲染的适配层。在适配层中，所有的绘图能力都由 Canvas 底层 API 提供，与浏览器 DOM 和其他的 API 无关。这样，SpriteJS 就能够运行在任何提供了 Canvas 运行时环境的系统中，而不一定是浏览器。

重构后的代码能够通过node-canvas运行在 Node.js 环境中，所以我们就能够使用服务端渲染来实现一些特殊的可视化项目。比如，我们曾经有一个项目要处理大量的历史数据，大概有几十万到上百万条记录，如果在前端分别绘制它们，性能一定会有问题。所以，我们将它们通过服务端绘制并缓存好之后，以图像的方式发送给前端，这样就大大提升了性能。此外，我们还通过在适配层上提供不同的封装，让 SpriteJS 2.0 支持了小程序环境，也能够运行在微信小程序中。

上图是 SpriteJS 2.0 的主体架构，它的底层由一些通用模块组成，Sprite-core 是适配层，SpriteJS 是支持浏览器和 Node.js 的运行时，Sprite-wxapp 是小程序运行时，Sprite-extend-* 是一些外部扩展。我们通过外部扩展实现了粒子系统和物理引擎，以及对主流响应式框架的支持，让 SpriteJS 2.0 可以直接支持vue和react。

SpriteJS 2.0通过扩展实现物理引擎

除此以外，SpriteJS 2.0 还支持了文字排版和布局系统。其中，文字排版支持了多行文本自动换行，实现了几乎所有 CSS3 支持的文字排版属性，布局系统则支持了完整的弹性布局（Flex layout)。这两个特性被很多用户喜爱。

可以说，我们对 SpriteJS 2.0 做了加法，让它在 1.0 的基础上增加了许多强大且有用的特性。到了 2019 年底，我又开始思考实现 SpriteJS 3.0。这次我打算对特性做一些取舍，将许多特性从 SpriteJS 3.0 中去掉，甚至包括深受使用者喜爱的文字排版和布局系统。这又是为什么呢？

这是因为 SpriteJS 2.0 虽好，但是它也有一些明显的缺点：

只支持 Canvas2D，尽管有缓存策略，性能仍然不足；

多平台适配采用不同的分支，维护起来比较麻烦；

支持了许多非核心功能，如文字排版、布局，使得 JavaScript 文件太大；

不支持 3D 绘图。

SpriteJS v3.x （2019 年~2020 年）

在 SpriteJS 3.0 中，我舍弃了非核心功能，将 SpriteJS 定位为纯粹的图形渲染引擎， 核心目标是追求极致的性能。

在适配层上，SpriteJS 3.0 完全舍弃了 2.0 设计里面较重的 sprite-core，采用了更轻量级的图形库mesh.js作为 2D 适配层，mesh.js 以 gl-renderer 作为 webgl 渲染底层库，结合 Canvas2D 的 polyfill 做到了优雅降级。当运行环境支持 WebGL2.0 时，SpriteJS 3.0 默认采用 WebGL2.0 渲染，否则降级为 WebGL1.0，如果也不支持 WebGL1.0，再最终降级为 Canvas2D。

在 3D 适配层方面，SpriteJS 3.0 采用了OGL库。这样一来，SpriteJS 3.0 就完全支持 WebGL 渲染，能够绘制 2D 和 3D 图形了。

SpriteJS 3.0 继承了 SpriteJS 2.0 的跨平台性，但是不再需要使用分支来适配多平台，而是采用了更轻量级的 polyfill 设计，同时支持服务端渲染、Web 浏览器渲染和微信小程序渲染，理论上讲还可以移植到其他支持 WebGL 或 Canvas2D 的运行环境中去。

SpriteJS 3.0 结构

与 SpriteJS 1.0 和 SpriteJS 2.0 采用缓存机制优化性能不同，SpriteJS 3.0 默认采用 WebGL 渲染，因此使用了批量渲染的优化策略，我们在性能篇中讲过这种策略，在绘制大量几何图形时，它能够显著提升 WebGL 渲染的性能。

由于发挥了 GPU 并行计算的能力，在大批量图形绘制的性能上，SpriteJS 3.0 的性能大约是 SpriteJS 2.0 的 100 倍。此外，SpriteJS 3.0 支持了多线程渲染，可避免 UI 阻塞，从而进一步提升性能。

SpriteJS 3.0 绘制5万个地理信息点，60fps帧率

总之，SpriteJS 3.0 随着性能的优化，已经成为一个纯粹的可视化渲染引擎了，但在我看来它仍然有些问题：

性能优化得不够极致，数据压缩和批量渲染没有做到最好；

JS 的矩阵运算还是不够快，计算性能有提升空间；

因为考虑到兼容性的问题，所以我采用了 Canvas2D 的降级，这让 JavaScript 包仍然有些大；

3D 能力不够强，与 ThreeJS 等主流 3D 引擎仍有差距。

SpriteJS 的未来版本（2020 年~2021 年）

今年下半年，我开始设计 SpriteJS 4.0。这一次，我打算把它打造成一个更纯粹的图形系统，让它可以做到真正跨平台，完全不依赖于 Web 浏览器。

下面是 SpriteJS 4.0 的结构图，它的底层将采用 OpenGL ES 和 Skia 来渲染 3D 和 2D 图形，中间层使用 JavaScript Core 和 JS Bindings 技术，将底层 Api 通过 JavaScript 导出，然后在上层适配层实现 WebGL、WebGPU 和 Canvas2D 的 API，最上层实现 SpriteJS 的 API。

SpriteJS 4.0 体系结构

根据这个设计，SpriteJS 4.0 将对浏览器完全没有依赖，同时依然可以通过 Web Assembly 方式运行在浏览器上。这样 SpriteJS 4.0 会成为真正跨平台的图形系统，可以以非常小的包集成到其他系统和原生 App 中，并且达到原生应用的性能。

在这一版，我还会全面优化 SpriteJS 的内存管理、矩阵运算和多线程机制，力求渲染性能再上一个台阶，最终能够完全超越现在市面上的任何主流的图形系统。

要点总结

在 SpriteJS 1.0 中，我们追求的是和 DOM 一致的 API，能够使用 D3.js 结合 SpriteJS 来绘制可视化图表到 Canvas，从而提升性能。到了 SpriteJS 2.0，我们追求跨平台能力和一些强大的功能扩展，比如文字排版和布局系统。而到了 SpriteJS 3.0，我们决定回归到渲染引擎本质，追求极致的性能发挥 GPU 的能力，并支持 3D 渲染。再到今年的 SpriteJS 4.0，我打算把它打造成更纯粹的图形系统，让它的渲染能力和性能最终能够超越目前市面上的主流图形系统。

总的来说，在 SpriteJS 1.0 到 4.0 的设计发展过程中，包含了我对整个图形系统架构的思考和取舍。我希望通过我今天的分享，能够帮助你理解图形系统和渲染引擎的设计，也期待在你设计其他系统和平台的时候，它们能给你启发。

课后思考

最后，请你试着回想你曾经接触过的可视化项目，如果用 SpriteJS 来实现它们会不会有更好的效果呢？欢迎把你的思考和答案写在留言区，我们一起讨论。

看了我给 SpriteJS 未来版本定下的目标，你有没有心动呢？SpriteJS 是一个开源项目，如果你学完这门课，也想参与进 SpriteJS 的开发，那我非常欢迎你成为一名 SpriteJS 开发者，为我们提交 PR、贡献代码。

好了，今天的内容就到这里，我们下节课见！

推荐阅读

D3.js

SpriteJS

Mesh.js

加餐五｜周爱民：我想和你分享些学习的道理

你好，我是月影。

专栏更新过半啦，我也收到了一些基础薄弱的用户反馈，说这门课难学。今天，我特意邀请了爱民老师，来和我们分享他的学习方法、心得。你可能会好奇，为什么今天没有音频。我希望你能静下心来，花上十几分钟，跟随着爱民老师一起思考学习的道理。

昔闻人言：天下之事，最轻易者莫过于求学，最恒难者，莫过于问道。予深以为然。

——引言

你好，我是周爱民。今天出现在这个课堂上，并不是因为我对月影兄所讲的“可视化”有着怎样的经验，也并没有什么应用技能或理论思想能和你交流。我在这里，想要与你讲的是“如何学习”。即便鼓了很大的勇气才领下这个题目，也不是因为我很善于学习。我在学习这件事上，是一个只会用笨功夫的人，没有什么技巧能跟你讲。

那我究竟为什么要来讲这个话题，又有什么可以跟你讲的呢？

这就要从同学们的感受说起了。“很难”，这大概是许多同学对月影这门课的直接感受。而这个感受，与引言中所谓“天下最轻易者”实在是天差地别。而我历来认为学习不同于问道：问道恒难，而学习甚易。这便是我想跟你聊一聊的道理。尤其重要的是，只要把这个道理听明白了，我想你学习起来也就轻松了。

正如我前面说过的，这不是学习的技巧，而是学习的道理。

为什么你会觉得难

很多人知道，我不是计算机专业出身的。在我决定自学计算机时，也并没有老师领路，因此，我需要自己解决的第一个问题便是：自修的话，先选什么课程。当时我是拿了计算机专业几年的课程表来看，然后把它们之间的关系理顺，最后决定先花一个学期单单只看一门《数据结构》。之所以这么选，是因为其它课程好像都要用到它，看起来也都与它有关。至于这其中的原因，我当时却是不明白的，因为没有人教，也没有人可以问。

那个时候，这门课用的是 Pascal，我没有语言基础，学校的电脑上也没有这个语言，所以我基本是手写代码，脑袋编译。至于一段代码运行的正确性，对不起，没有上机测试，所以我也不知道。就这样忽忽的学了一个学期，我去考了试，得了 70 多分。

于是，我的《数据结构》的底子就这样扎下了。自那以后，我在计算机语言、应用这方面，再没遇到比当时更难的困境。这其中的道理很简单，只要扎实基础，循序渐进，几乎所有语言与代码方面的问题，我们都可以一点点啃下去。 直到如今，如果有人去看我写的那本《程序原本》，就能看得出这个硬底子还在。

当然，一定会有同学说：我的《数据结构》成绩比你还好，但一样觉得很难啊！

这是正常的。上面我说的这个道理，其实只说了一层。要知道我从业二十多年来，像《数据结构》这样一门一门、一本一本啃下来的课，也并不止一个。如今看起来学什么都容易，只不过是积腋成裘的结果罢了。在这个过程中，但凡我觉得有“非常之疑难”者，无不是中间少啃了那么一本两本“《数据结构》”。也就是说，那些称得上“疑难不懂”的，其实都只是缺了几门知识，少花了几次笨功夫而已。所以要学好月影这门课，且先不说他如何讲，你只要笃定一个笨想法就可以了：你看到不懂的名词，必然是少学了几门功课；看到不懂的话语，必然是少推断了几个逻辑，等等如此。

有着这些种种不懂，也就当然有种种能懂的知识。你把这些坑坑洞洞都补起来，自然也就懂了。只要你愿意像在学校里一样地去做笨功夫，这些缺失的知识又怎么算得上难呢？

为什么你会听不明白

不过很多人所面对的，倒不见得是“难”，而是所谓“疑”。疑难二字，其实并不是同一个东西：你攀山越岭时，所感觉到的便是“难”，只要努力，一座座地翻过去，总还是可以到达的。而你隔岸观花时，那种似是而非才是“疑”。

之所以有“疑”，并不是目力不济，而是心志不济。

在《程序原本》这本书的前言致谢中，我感谢了我的小学数学老师，因此便有同学问我其中的缘故。我说：那本书中，所用到的数学知识，止于小学足矣。然而这本书却是我在极客时间开设《JavaScript 核心原理解析》一课的基础，所以如今反观这本书、这门课程，其实在数学知识上都是“止于小学足矣”。

所以我很想问，都这样了，为什么还有人觉得“难”？又或者问，既然已是“如此这般地不难”了，那么你所“疑”的，又是什么呢？

但凡我们受过高等教育的，又或者写过几年程序的，都不妨把自己看成是“有知识、有经验”的人。这很正常，我们本来就是靠着这些知识与经验吃饭的，这都是看家底儿的东西。然而，这些东西“正确”吗？我们之所“疑”的，往往就是所“见到”的与我们所“知道”的之间的矛盾。比如说在《JavaScript 核心原理解析》中，第一讲便是说“引用”这个概念的，然而 ECMAScript 中的所谓“引用”，与一般语言中的“引用”是似是而非的。所以，从这门课一开始，就有无数同学深陷于这一个概念之中，用既有的知识来解释它：有解释为指针的，有解释为结构的，有解释为类型的，……不一而足。

为什么有“疑”？其实这是缘于我们对“已知”的不疑。当这种不疑与面前的真相矛盾时，我们就怀疑了、拒绝了、不可接受了。学习中怕的不是看不懂，而是明明表面什么都看得懂，心底里却什么都不接受。这才可怕，这才畏怯，这才寸厘不进。

这也是很多课程“学不明白”的根由：你从一开始，就放不下那些你认为“对”的东西；既然你认为已看到的是对的，那么眼前所学的，又是什么呢？

所以要解决学习中的“疑”，反倒是放开态度，认为自己什么也不懂，心志坚定地跟着眼前这位老师的步子一点点看，一遍遍练。这个过程，并不是要怀疑自己，而是要敢于舍弃自己。相信他人既然可以为师，必有所长，必不吝于教，无有成见地去学习，才能学有所得，进而更新自己。

在《大道至简》中说过“问道于盲”的故事：只要你愿意闭上眼睛，你也可以向盲人问道。睁开眼问，这止于眼前的所谓“真相”，便是你的疑难了。

为什么你觉得什么也没学会

其实月影这门课还有一个副标题，是“系统掌握图形学与可视化核心原理”，这与《JavaScript 核心原理解析》有一个关键词的重合。并且，事实上在我那门课程的开篇中，我还专门提到这门课的要点是“体系性（系统）”，这算得上另一处巧合。

不过，“核心原理”+“体系性”却并不见得是“你觉得难”的根源。说到这里，恐怕又有不少人要跳起来反对了：都讲“核心原理”了，还不难啊？！

其实核心原理通常来说真的不难。例如密码学，核心原理就是映射变换；图形学，核心原理就是点（位置 / 坐标）等等。同样的，“体系性”也并不难，例如月影的课程，体系性就摆在那里了，你去看看课程目录就知道了。所以无论是体系，还是核心原理，都不是这些课程让你觉得难的真正原因。

这让我想起了《大道至简》这本书最后有一个“问智”的桥段。起首的一问是“何谓愚”，书中说的是：不知道事情的起始，也不知道它的终点，就是所谓的“愚”了。而在我看来，一门课程要是学不好、学不懂、学不通，起步上的错处，就在你对于要学会的东西“不知其始、非知所终”。

不管是什么课程，你得先知道老师“想讲什么”，这就是结果。如果一个课程你知道了结果，并且又假设结果是对的，那么学习它的方法就是按照老师所讲的内容，一步步推演过去就好了。这个过程是确定的，结果也是确定的，你所需的无非是推演中所缺的几门功课，以及一点耐心而已。所谓前人、所谓先行者，其实无非就是比你多走了一遍路途而已，所以只要我们放下自我的姿态，亦步亦趋地跟着老师讲的往前走，那么这个结果总是能看到的。

学习之“易”也就在于此：总有一个确定的过程，以及一个确定的结果。但是，这往往还不够。因为如果老师只教你这个结果，那也只是最低一等的“师”。无论如何，真正的“良师”，会帮助你解决“不知起始”的问题。——于此，你真正还需要知道的是老师“为什么要教”。一旦你知道了“教的目的”，也就知道了复核自己“学的结果”的方法，这就是相互印证了。如果学习不知道起始、不知道目的、不知道印证，那么怎么学都是茫茫然。

而核心原理和体系性的“易”也就在于此：核心原理总是一个简单的结论，你所学的，只是这个结论得到的过程；体系性总是一种明确的结构（组合），你所学的，只是将这个结构组织起来的方法。所以最终来说，你就是要得到一个核心推论的过程，以及了解它所涉及的那些领域之间的关系而已。

多数情况下，就结果来说，你甚至可以从课程目录，以及每章的小节中直接读到它们。那么不管它与你既有的知识是否有冲突，将这门课程粗略地看过去，拿了这样的结果（核心过程与结构关系）就可以了。

不过，学习这样的课程，不要在“结果”上花功夫，从过程上去找、去看方法，才是正途。

道理我都懂

正如引言中所说，学习这件事情，其实真的不是很难。至于是不是“天下最轻易者”，我可不敢跟古人去论争。所以在今天的分享里，我也只是想把一些似难实简的地方跟你说说：其一，你可能只是缺了点知识，要补课；其二，你可能只是宥于所知，请开放思想；其三，你可能只是埋头学习，而忘了要学会的是什么。

是的，我知道已经有人开始在说：“道理我都懂……”。

这种腹诽，实在是这么些年来我最怕见到的。因为持这种态度者，大多都是不愿下苦功夫的人。所以我希望读到这里，心底下还叹着“道理我都懂”的朋友，能再回到第一小节，从苦功夫做起。要知道古往今来之有成就者，不过将学习持之以恒而已，哪里有什么技巧。

不过如果要舍了学习不谈，只想拿个现成的东西去用，那么作为程序员，最好的地方是在 GitHub，而不是在极客时间。我想，这个看起来众所周知的答案，便是我对那些急于掩卷而去的人，最后的劝慰了。

加餐四 | 一篇文章，带你快速理解函数式编程

你好，我是月影。今天，我们来讨论函数式编程。

我看到很多同学留言说，课程中给出的代码例子有的地方看不明白。我把同学们看不懂的地方汇总了一下，发现大部分都是我使用函数式编程来写的。比如，我在第 7 讲说过的 parametric 高阶函数，第 12 讲说过的 traverse 的设计，还有第 15 讲中使用的 toPolar/fromPolar 和改进版的 parametric 设计，以及数据篇中的数据处理和 D3.js 的使用。

如果你还不习惯函数式编程思想，并且也觉得这些代码不容易理解，想知道为什么一定要这么设计，那这节课，你一定要好好听，我会和你系统地说说过程抽象和函数式编程这个话题。

两种编程范式：命令式与声明式

首先，我先来说说什么是编程范式。编程范式有两种，分别是命令式（Imperative）和声明式（Declarative），命令式强调做的步骤也就是怎么做，而声明式强调做什么本身，以及做的结果。因此，编程语言也可以分成命令式和声明式两种类型，如果再细分的话，命令式又可以分成过程式和面向对象，而声明式则可以分成逻辑式和函数式。下面这张图列出了编程语言的分类和每个类型下经典的编程语言。

你注意看，这张图里并没有 JavaScript。实际上像 JavaScript 这样的现代脚本语言，通常具有混合范式，也就是说 JavaScript 同时拥有命令式和声明式的特征。因此开发者可以同时用 JavaScript 写出命令式与声明式风格的代码。举个例子，我们要遍历一个数组，将每一个元素的数值翻倍，我们可以分别用命令式和声明式来实现。

首先，是命令式的实现代码：

let list = [1, 2, 3, 4];

let map1 = [];
for(let i = 0; i < list.length; i++){
  map1.push(list[i] * 2);
}

然后是声明式的实现代码：

let list = [1, 2, 3, 4];
const double = x => x * 2;
list.map(double);

从上面的代码我们可以看到，虽然两段代码的目的相同，但是具体的实现手段差别很大。其中命令式强调怎么做，使用的是 for 循环来遍历，而声明式强调做什么，用到了 double 算子。

函数式与纯函数

既然编程风格有命令式和声明式，为什么我们在一些设计中更多会选择声明式风格的函数式编程，它究竟有什么好处呢？通过和前面的代码对比，我们看到似乎声明式（函数式）代码写起来更加简洁。是的，大部分情况下，函数式编程的代码更加简洁。但除了能减少代码量之外，函数式还有什么具体的好处呢？这个就要从纯函数说起了。

我们知道，函数是对过程的封装，但函数的实现本身可能依赖外部环境，或者有副作用（Side-effect)。所谓函数的副作用，是指函数执行本身对外部环境的改变。我们把不依赖外部环境和没有副作用的函数叫做纯函数，依赖外部环境或有副作用的函数叫做非纯函数。

这里，我们先来看一组例子：

function add(x, y) {
  return x + y;
}

function getEl(id) {
  return document.getElementById(id);
}

funciton join(arr1, arr2) {
  arr1.push(...arr2);
  return arr1;
}

在上面的代码中，add 是一个纯函数，它的返回结果只依赖于输入的参数，与调用的次数、次序、时机等等均无关。而 getEl 是一个非纯函数，它的返回值除了依赖于参数 id，还和外部环境（文档的 DOM 结构）有关。另外，join 也是一个非纯函数，它的副作用是会改变输入参数对象本身的内容，所以它的调用次数、次序和时机不同，我们得到的结果也不同。

纯函数的优点

现在我们知道了纯函数与非纯函数的区别，但我们又为什么要人为地把函数划分为纯函数和非纯函数呢？这是因为纯函数与非纯函数相比，有三个非常大的优点，分别是易于测试（上下文无关）、可并行计算（时序无关）、有良好的 Bug 自限性。下面，我一一来解释一下。

首先纯函数易于测试，在用单元测试框架的时候，因为纯函数不需要依赖外部环境，所以我们直接写一个简单的测试 case 就可以了。

//test with pure functions
test(t => {
  dosth...

  done!
});

而非纯函数因为比较依赖外部环境，在测试的时候我们还需要构建外部环境。

//test with impure functions

//always need hooks
test.before(t => {
  //setup environments
});

test.after('cleanup', t => {
  //clean
});

test(t => {
  dosth...

  done!
});

其次，纯函数可以并行计算。在浏览器中，我们可以利用 Worker 来并行执行多个纯函数，在 Node.js 中，我们也可以用 Cluster 来实现同样的并行执行，而使用 WebGL 的时候，纯函数有时候还可以转换为 Shader 代码，利用 GPU 的特性来进行计算。

最后，纯函数有良好的 Bug 自限性。这是什么意思呢？因为纯函数不会依赖和改变外部环境，所以它产生的 Bug 不会扩散到系统的其他部分。而非纯函数，尤其是有副作用的非纯函数，在产生 Bug 后，因为 Bug 可能意外改变了外部环境，所以问题会扩散到系统其他部分。这样在调试的时候，就算发现了 Bug，你可能也找不到真正导致 Bug 的原因，这就给系统的维护和 Bug 追踪带来困难。

总而言之，我们设计系统的时候，要尽可能多设计纯函数，少设计非纯函数，这样能够有效提升系统的可测试性、性能优化空间以及系统的可维护性。

函数式编程范式与纯函数

那么问题来了，我们该如何让系统的纯函数尽可能多，非纯函数尽可能少呢？答案是用函数式编程范式。我们还是通过一个例子来理解。

我们要实现一个模块，用它来操作 DOM 中列表元素，改变元素的文字颜色，具体的实现代码如下：

function setColor(el, color){
  el.style.color = color;
}

function setColors(els, color){
  els.forEach(el => setColor(el, color));
}

这个模块中有两个方法，其中 setColor 是操作一个 DOM 元素，改变它的文字颜色，而 setColors 则是批量操作若干个 DOM 元素，改变所有元素的颜色。

尽管这两个方法都非常简单，但它们都改变了外部环境（DOM）所以它们是两个非纯函数。因此，我们在做系统测试的时候，两个方法都需要构建外部环境来实现测试。

如果想让系统测试更简单，我们是不是可以采用函数式编程思想，把非纯函数的个数减少一个呢？当然可以，我们可以实现一个 batch 函数来优化。batch 函数接受的参数是一个函数 f，就会返回一个新的函数。在这个过程中，我们要遵循的调用规则是，如果这个参数有 length 属性，我们就以数组来遍历这个参数，用每一个元素迭代 f，否则直接用当前调用参数来调用 f 就可以了。

具体的实现代码如下：

function batch(fn){
  return function(target, ...args){
    if(target.length >= 0){
      return Array.from(target).map(item => fn.apply(this, [item, ...args]));
    }else{
      return fn.apply(this, [target, ...args]);
    }
  }
}

因为 batch 函数的参数和返回值都是函数，所以它有一个专属的名字，高阶函数 (High Order Function)。高阶函数虽然看上去复杂，但它实际上就是一个纯函数。它的执行结果只依赖于参数（传入的函数），与外部环境无关。

我们可以测试一下这个 batch 函数的正确性，方法十分简单只要用下面这个 Case 就行了。

test(t => {
  let add = (x, y) => x + y;
  let listAdd = batch(add);

  t.deepEqual(listAdd([1,2,3], 1), [2,3,4]);
});

有了 batch 函数之后，我们的模块就可以减少为一个非纯函数。

function setColor(el, color){
  el.style.color = color;
}

let setColors = batch(setColor);

这里我们用 batch 来实现 setColors，只要 batch 实现正确，setColors 的行为就可以保证是正确的。

高阶函数与函数装饰器

刚才我说，batch 是一个高阶函数。所谓高阶函数，是指输入参数是函数，或者返回值是函数的函数。

如果输入参数和返回值都是函数，这样的高阶函数又叫做函数装饰器（Function Decorators）。当一个高阶函数是用来修饰函数本身的，它就是函数装饰器。也就是说，它是在原始函数上增加了某些带有辅助功能的函数。

这么说你可能不太理解，我们再来看一个例子。

假设，我们的代码库要进行大版本升级，在未来最新的版本中我们想要废弃掉某些 API，由于很多业务中使用了老版本的库，不可能一次升级完，因此我们需要做一个平缓过渡。具体来说就是在当前这个版本中，先不取消这些旧的 API，而是给它们增加一个提示信息，告诉调用它们的用户，这些 API 将会在下一次升级中被废弃。

如果我们手工修改要废弃的 API 代码，这会是一件非常繁琐的事情。而且，我们很容易遗漏或者弄错些什么，从而产生不可预料的 Bug。

所以，一个比较聪明的办法是，我们实现一个通用的函数装饰器。

function deprecate(fn, oldApi, newApi) {
  const message = `The ${oldApi} is deprecated.
Please use the ${newApi} instead.`;

  return function(...args) {
    console.warn(message);
    return fn.apply(this, args);
  }
}

然后，在模块导出 API 的时候，对需要废弃的方法统一应用这个装饰器。

// deprecation.js
// 引入要废弃的 API
import {foo, bar} from './foo';
...
// 用高阶函数修饰
const _foo = deprecate(foo, 'foo', 'newFoo');
const _bar = deprecate(bar, 'bar', 'newBar');

// 重新导出修饰过的API
export {
  foo: _foo,
  bar: _bar,
  ...
}

这样，我们就利用函数装饰器，无侵入地修改了模块的 API，将要废弃的模块用 deprecate 包装之后再输出，就实现了我们想要的效果。这里，我们实现的 deprecate 就是一个纯函数，它的维护和使用都非常简单。

过程抽象

理解了前面的例子之后，咱们再回过头来，说说课程中的函数式编程。我们直接来看第 7 节课里 parametric 函数的实现。

function parametric(xFunc, yFunc) {
  return function (start, end, seg = 100, ...args) {
    const points = [];
    for(let i = 0; i <= seg; i++) {
      const p = i / seg;
      const t = start * (1 - p) + end * p;
      const x = xFunc(t, ...args); // 计算参数方程组的x
      const y = yFunc(t, ...args);  // 计算参数方程组的y
      points.push([x, y]);
    }
    return {
      draw: draw.bind(null, points),
      points,
    };
  };

如上面代码所示，parametric 是一个高阶函数，它比上面的函数装饰器更加复杂一点的是，它的输入是两个函数 xFunc 和 yFunc，输出也是一个函数，返回的这个函数实际上是一个过程，这个过程是对 x、y 的参数方程根据变量 t 的值进行采样。

所以，实际上 parametric 函数封装的是一个过程，这种封装过程的思路，叫做过程抽象。前面的函数装饰器，还有 batch 方法，实际上也是过程抽象。对应的一般程序设计中我们不是封装过程，而是封装数据，所以叫做数据抽象。

过程抽象是函数式编程的基础，函数式编程对待函数就像对待数据一样，都会进行封装和抽象，这样能够设计出非常通用的功能模块。

要点总结

函数式编程的内容非常多，这一节课，我只是借助了这些基础的概念和代码，把你带进了函数式编程的大门。

首先，我们了解了两种不同的编程范式，分别是命令式和声明式。其中，函数式属于声明式，而过程式和面向对象则属于命令式。JavaScript 语言是同时具有命令式和声明式特征的编程语言。

然后，我们知道函数式有一个非常大的优点，就是能够减少非纯函数的数量，这也是我们设计系统时要遵循的原则。因为相比于非纯函数，纯函数具有更好的可测试性、执行效率和可维护性。

最后，我们还学会了使用高阶函数和函数装饰器来设计纯函数，实现通用的功能。这种思路是对过程封装，所以叫做过程抽象，它是函数式编程的基础。

小试牛刀

如果你了解 react，你会发现 react-hooks 其实上就是纯函数设计。你可以思考一下，如果引入了它，能给你的系统带来什么好处？

我们在前端业务中，也会用到一些常用的函数装饰器，比如，节流 throttle 和防抖 debounce，你能说说它们的使用场景吗？如果让你实现这两个函数装饰器，你又会怎么做呢？

函数式编程的思想你都理解了吗？那不妨也把这节课分享给你的朋友吧。今天的内容就到这里了，我们下节课见！

国庆策划 | 假期别闲着，一起来挑战“手势密码”

你好，我是月影。

首先祝你中秋快乐！在国庆、中秋的 8 天假期里，我特意为你准备了一道动手实操题。这道题虽然不在我们的课程设计中，但我把它特意安排在国庆假期，就是希望你能有充足的时间来动手练习。在这道题里，你需要自己思考怎么设计 API、选择合适的技术方案，以及着手去解决和优化一些细节性问题。

希望通过这道题，能够帮助你进一步理解如何开发一个比较复杂的 UI 组件。这对于理解可视化也有借鉴作用，因为一些复杂的可视化效果，本身也类似于实现一个复杂的 UI 组件。

不过，在正式开始做题之前，我还有一个要求，我希望你能自己去思考解题方法，真正动手去实践，不要依赖网上的答案，这样才能真正找出自己的不足。

话不多说，我们直接进入实战吧！

在移动端设备上，“手势密码”是一个很常用的 UI 组件。一个手势密码的界面大致如下所示。用户用手指按顺序依次划过 9 个原点中的若干个（必须不少于 4 个点），如果划过的点的数量和顺序与之前用户设置的相同，那么当用户的手指离开屏幕时，判定为密码输入正确，否则密码错误。

要求：现在你需要实现一个移动网页，允许用户设置手势密码和验证手势密码。已设置的密码记录在本地 localStorage 中。接下来，我把要实现的界面原型和操作流程告诉你。

第一步，设置密码。用户选择设置密码时，要提示用户输入手势密码。

第二步，密码长度太短。如果用户输入的密码不足 5 个点，提示用户密码太短。

第三步，设置成功一次密码后，提示用户再次输入密码。

第四步，如果用户输入的两次密码不一致，提示用户重新开始设置密码，并重置。

第五步，如果两次输入一致，密码设置成功，更新 localStorage。

第六步，验证密码的正确性。切换单选框进入验证密码模式，将用户输入的密码与保存的密码相比较，如果不一致，则提示输入密码不正确，重置为等待用户输入。

如果用户输入的密码与 localStorage 中保存的密码一致，则提示密码正确。

最后，请你按照上面的需求实现这个网页，让它在手机上可用。你可以不用考虑古老机器的兼容性问题，只需要让最新的 Android 和 iPhone 可用就可以了。

完整的题目就是这样，希望你能又快又好地实现它。实现完成后，可以在留言区分享你的思路和代码，期待你的作品。

国庆策划 | 快来看看怎么用原生JavaScript实现手势解锁组件

你好，我是月影。前几天，我给你出了一道实操题，不知道你完成得怎么样啦？

今天，我就给你一个参考版本。当然，并不是说这一版就最好，而是说，借助这一版的实现，我们就能知道当遇到这样比较复杂的 UI 需求时，我们应该怎样思考和实现。

首先，组件设计一般来说包括 7 个步骤，分别是理解需求、技术选型、结构（UI）设计、数据和 API 设计、流程设计、兼容性和细节优化，以及工具和工程化。

当然了，并不是每个组件设计的时候都需要进行这些过程，但一个项目总会在其中一些过程里遇到问题需要解决。所以，下面我们来做一个简单的分析。

理解需求

上节课的题目本身只是说设计一个常见的手势密码的 UI 交互，那我们就可以通过选择验证密码和设置密码来切换两种状态，每种状态有自己的流程。

如果你就照着需求把整个组件的状态切换和流程封装起来，或者只是提供了一定的 UI 样式配置能力的话，还远远不够。实际上这个组件如果要给用户使用，我们需要将过程节点开放出来。也就是说，需要由使用者决定设置密码的过程里执行什么操作、验证密码的过程和密码验证成功后执行什么操作，这些是组件开发者无法代替使用者来决定的。

var password = '11121323';

var locker = new HandLock.Locker({
  container: document.querySelector('#handlock'),
  check: {
    checked: function(res){
      if(res.err){
        console.error(res.err); //密码错误或长度太短
        [执行操作...]
      }else{
        console.log(`正确，密码是：${res.records}`);
        [执行操作...]
      }
    },
  },
  update:{
    beforeRepeat: function(res){
      if(res.err){
        console.error(res.err); //密码长度太短
        [执行操作...]
      }else{
        console.log(`密码初次输入完成，等待重复输入`);
        [执行操作...]
      }
    },
    afterRepeat: function(res){
      if(res.err){
        console.error(res.err); //密码长度太短或者两次密码输入不一致
        [执行操作...]
      }else{
        console.log(`密码更新完成，新密码是：${res.records}`);
        [执行操作...]
      }
    },
  }
});

locker.check(password)

技术选型

这个问题的 UI 展现的核心是九宫格和选中的小圆点，从技术上来讲，我们有三种可选方案： DOM/Canvas/SVG，三者都是可以实现主体 UI 的。那我们该怎么选择呢？

如果使用 DOM，最简单的方式是使用 flex 布局，这样能够做成响应式的。使用 DOM 的优点是容易实现响应式，事件处理简单，布局也不复杂（但是和 Canvas 比起来略微复杂），但是斜线（demo 里没有画）的长度和斜率需要计算。

除了使用 DOM 外，使用 Canvas 绘制也很方便。用 Canvas 实现有两个小细节，一是要实现响应式，我们可以用 DOM 构造一个正方形的容器。这里，我们使用 padding-top:100% 撑开容器高度使它等于容器宽度。 代码如下：

#container {
  position: relative;
  overflow: hidden;
  width: 100%;
  padding-top: 100%;
  height: 0px;
  background-color: white;
}

第二个细节是为了在 retina 屏上获得清晰的显示效果，我们将 Canvas 的宽高增加一倍，然后通过 transform: scale(0.5) 来缩小到匹配容器宽高。

#container canvas{
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%) scale(0.5);
}

由于 Canvas 的定位是 absolute，它本身的默认宽高并不等于容器的宽高，需要通过 JavaScript 设置。

let width = 2 * container.getBoundingClientRect().width;
canvas.width = canvas.height = width;

使用上面的代码，我们就可以通过在 Canvas 上绘制实心圆和连线来实现 UI 了。具体的方法，我下面会详细来讲。

最后，我们来看一下使用 SVG 的绘制方法。不过，由于 SVG 原生操作的 API 不是很方便，我们可以使用了 Snap.svg 库，实现起来和使用 Canvas 大同小异，我就不详细来说了。但是，SVG 的问题是移动端兼容性不如 DOM 和 Canvas 好，所以综合上面三者的情况，我最终选择使用 Canvas 来实现。

结构设计

使用 Canvas 实现的话， DOM 结构就比较简单了。为了实现响应式，我们需要实现一个自适应宽度的正方形容器，方法前面已经讲过了，然后我们在容器中创建 Canvas。

这里需要注意的一点是，我们应当把 Canvas 分层。这是因为 Canvas 的渲染机制里，要更新画布的内容，需要刷新要更新的区域重新绘制。因此我们有必要把频繁变化的内容和基本不变的内容分层管理，这样能显著提升性能。

在这里我把 UI 分别绘制在 3 个图层里，对应 3 个 Canvas。最上层只有随着手指头移动的那个线段，中间是九个点，最下层是已经绘制好的线。之所以这样分，是因为随手指头移动的那条线需要不断刷新，底下两层都不用频繁更新，但是把连好的线放在最底层是因为我要做出圆点把线的一部分遮挡住的效果。

接着，我们确定圆点的位置。

圆点的位置有两种定位法，第一种是九个九宫格，圆点在小九宫格的中心位置。认真的同学肯定已经发现了，在前面 DOM 方案里，我们就是采用这样的方式。这个时候，圆点的直径为 11.1%。第二种方式是用横竖三条线把宽高四等分，圆点在这些线的交点处。

在 Canvas 里我们采用第二种方法来确定圆点（代码里的 n = 3）。

let range = Math.round(width / (n + 1));

let circles = [];

//drawCircleCenters
for(let i = 1; i <= n; i++){
  for(let j = 1; j <= n; j++){
    let y = range * i, x = range * j;
    drawSolidCircle(circleCtx, fgColor, x, y, innerRadius);
    let circlePoint = {x, y};
    circlePoint.pos = [i, j];
    circles.push(circlePoint);
  }

最后一点严格说不属于结构设计，但因为我们的 UI 是通过触屏操作，所以我们需要考虑 Touch 事件处理和坐标的转换。

function getCanvasPoint(canvas, x, y){
  let rect = canvas.getBoundingClientRect();
  return {
    x: 2 * (x - rect.left),
    y: 2 * (y - rect.top),
  };
}

我们将 Touch 相对于屏幕的坐标转换为 Canvas 相对于画布的坐标。代码里的 2 倍是因为我们前面说了要让 retina 屏下清晰，我们将 Canvas 放大为原来的 2 倍。

API 设计

接下来我们需要设计给使用者使用的 API 了。在这里，我们将组件功能分解一下，独立出一个单纯记录手势的 Recorder。将组件功能分解为更加底层的组件，是一种简化组件设计的常用模式。

我们抽取出底层的 Recorder，让 Locker 继承 Recorder，Recorder 负责记录，Locker 管理实际的设置和验证密码的过程。

我们的 Recorder 只负责记录用户行为，由于用户操作是异步操作，我们将它设计为 Promise 规范的 API，它可以以如下方式使用：

var recorder = new HandLock.Recorder({
  container: document.querySelector('#main')
});

function recorded(res){
  if(res.err){
    console.error(res.err);
    recorder.clearPath();
    if(res.err.message !== HandLock.Recorder.ERR_USER_CANCELED){
      recorder.record().then(recorded);
    }
  }else{
    console.log(res.records);
    recorder.record().then(recorded);
  }
}

recorder.record().then(recorded)

对于输出结果，我们简单用选中圆点的行列坐标拼接起来得到一个唯一的序列。例如 “11121323” 就是如下选择图形：

为了让 UI 显示具有灵活性，我们还可以将外观配置抽取出来。

const defaultOptions = {
  container: null, //创建canvas的容器，如果不填，自动在 body 上创建覆盖全屏的层
  focusColor: '#e06555',  //当前选中的圆的颜色
  fgColor: '#d6dae5',     //未选中的圆的颜色
  bgColor: '#fff',        //canvas背景颜色
  n: 3, //圆点的数量： n x n
  innerRadius: 20,  //圆点的内半径
  outerRadius: 50,  //圆点的外半径，focus 的时候显示
  touchRadius: 70,  //判定touch事件的圆半径
  render: true,     //自动渲染
  customStyle: false, //自定义样式
  minPoints: 4,     //最小允许的点数
};

这样，我们实现完整的 Recorder 对象，核心代码如下：

[...] //定义一些私有方法

const defaultOptions = {
  container: null, //创建canvas的容器，如果不填，自动在 body 上创建覆盖全屏的层
  focusColor: '#e06555',  //当前选中的圆的颜色
  fgColor: '#d6dae5',     //未选中的圆的颜色
  bgColor: '#fff',        //canvas背景颜色
  n: 3, //圆点的数量： n x n
  innerRadius: 20,  //圆点的内半径
  outerRadius: 50,  //圆点的外半径，focus 的时候显示
  touchRadius: 70,  //判定touch事件的圆半径
  render: true,     //自动渲染
  customStyle: false, //自定义样式
  minPoints: 4,     //最小允许的点数
};

export default class Recorder{
  static get ERR_NOT_ENOUGH_POINTS(){
    return 'not enough points';
  }
  static get ERR_USER_CANCELED(){
    return 'user canceled';
  }
  static get ERR_NO_TASK(){
    return 'no task';
  }
  constructor(options){
    options = Object.assign({}, defaultOptions, options);

    this.options = options;
    this.path = [];

    if(options.render){
      this.render();
    }
  }
  render(){
    if(this.circleCanvas) return false;

    let options = this.options;
    let container = options.container || document.createElement('div');

    if(!options.container && !options.customStyle){
      Object.assign(container.style, {
        position: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        height: '100%',
        lineHeight: '100%',
        overflow: 'hidden',
        backgroundColor: options.bgColor
      });
      document.body.appendChild(container);
    }
    this.container = container;

    let {width, height} = container.getBoundingClientRect();

    //画圆的 canvas，也是最外层监听事件的 canvas
    let circleCanvas = document.createElement('canvas');

    //2 倍大小，为了支持 retina 屏
    circleCanvas.width = circleCanvas.height = 2 * Math.min(width, height);
    if(!options.customStyle){
      Object.assign(circleCanvas.style, {
        position: 'absolute',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%) scale(0.5)',
      });
    }

    //画固定线条的 canvas
    let lineCanvas = circleCanvas.cloneNode(true);

    //画不固定线条的 canvas
    let moveCanvas = circleCanvas.cloneNode(true);

    container.appendChild(lineCanvas);
    container.appendChild(moveCanvas);
    container.appendChild(circleCanvas);

    this.lineCanvas = lineCanvas;
    this.moveCanvas = moveCanvas;
    this.circleCanvas = circleCanvas;

    this.container.addEventListener('touchmove',
      evt => evt.preventDefault(), {passive: false});

    this.clearPath();
    return true;
  }
  clearPath(){
    if(!this.circleCanvas) this.render();

    let {circleCanvas, lineCanvas, moveCanvas} = this,
        circleCtx = circleCanvas.getContext('2d'),
        lineCtx = lineCanvas.getContext('2d'),
        moveCtx = moveCanvas.getContext('2d'),
        width = circleCanvas.width,
        {n, fgColor, innerRadius} = this.options;

    circleCtx.clearRect(0, 0, width, width);
    lineCtx.clearRect(0, 0, width, width);
    moveCtx.clearRect(0, 0, width, width);

    let range = Math.round(width / (n + 1));

    let circles = [];

    //drawCircleCenters
    for(let i = 1; i <= n; i++){
      for(let j = 1; j <= n; j++){
        let y = range * i, x = range * j;
        drawSolidCircle(circleCtx, fgColor, x, y, innerRadius);
        let circlePoint = {x, y};
        circlePoint.pos = [i, j];
        circles.push(circlePoint);
      }
    }

    this.circles = circles;
  }
  async cancel(){
    if(this.recordingTask){
      return this.recordingTask.cancel();
    }
    return Promise.resolve({err: new Error(Recorder.ERR_NO_TASK)});
  }
  async record(){
    if(this.recordingTask) return this.recordingTask.promise;

    let {circleCanvas, lineCanvas, moveCanvas, options} = this,
        circleCtx = circleCanvas.getContext('2d'),
        lineCtx = lineCanvas.getContext('2d'),
        moveCtx = moveCanvas.getContext('2d');

    circleCanvas.addEventListener('touchstart', ()=>{
      this.clearPath();
    });

    let records = [];

    let handler = evt => {
      let {clientX, clientY} = evt.changedTouches[0],
          {bgColor, focusColor, innerRadius, outerRadius, touchRadius} = options,
          touchPoint = getCanvasPoint(moveCanvas, clientX, clientY);

      for(let i = 0; i < this.circles.length; i++){
        let point = this.circles[i],
            x0 = point.x,
            y0 = point.y;

        if(distance(point, touchPoint) < touchRadius){
          drawSolidCircle(circleCtx, bgColor, x0, y0, outerRadius);
          drawSolidCircle(circleCtx, focusColor, x0, y0, innerRadius);
          drawHollowCircle(circleCtx, focusColor, x0, y0, outerRadius);

          if(records.length){
            let p2 = records[records.length - 1],
                x1 = p2.x,
                y1 = p2.y;

            drawLine(lineCtx, focusColor, x0, y0, x1, y1);
          }

          let circle = this.circles.splice(i, 1);
          records.push(circle[0]);
          break;
        }
      }

      if(records.length){
        let point = records[records.length - 1],
            x0 = point.x,
            y0 = point.y,
            x1 = touchPoint.x,
            y1 = touchPoint.y;

        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        drawLine(moveCtx, focusColor, x0, y0, x1, y1);
      }
    };

    circleCanvas.addEventListener('touchstart', handler);
    circleCanvas.addEventListener('touchmove', handler);

    let recordingTask = {};
    let promise = new Promise((resolve, reject) => {
      recordingTask.cancel = (res = {}) => {
        let promise = this.recordingTask.promise;

        res.err = res.err || new Error(Recorder.ERR_USER_CANCELED);
        circleCanvas.removeEventListener('touchstart', handler);
        circleCanvas.removeEventListener('touchmove', handler);
        document.removeEventListener('touchend', done);
        resolve(res);
        this.recordingTask = null;

        return promise;
      }

      let done = evt => {
        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        if(!records.length) return;

        circleCanvas.removeEventListener('touchstart', handler);
        circleCanvas.removeEventListener('touchmove', handler);
        document.removeEventListener('touchend', done);

        let err = null;

        if(records.length < options.minPoints){
          err = new Error(Recorder.ERR_NOT_ENOUGH_POINTS);
        }

        //这里可以选择一些复杂的编码方式，本例子用最简单的直接把坐标转成字符串
        let res = {err, records: records.map(o => o.pos.join('')).join('')};

        resolve(res);
        this.recordingTask = null;
      };
      document.addEventListener('touchend', done);
    });

    recordingTask.promise = promise;

    this.recordingTask

这里有几个公开的方法，分别是 ecorder 负责记录绘制结果， clearPath 负责在画布上清除上一次记录的结果，cancel 负责终止记录过程，这是为后续流程准备的。

流程设计

接下来，我们基于 Recorder 来设计设置和验证密码的流程：

首先是验证密码的流程：

其次是设置密码的流程：

有了前面异步 Promise API 的 Recorder，我们不难实现上面的两个流程。

验证密码的内部流程

async check(password){
  if(this.mode !== Locker.MODE_CHECK){
    await this.cancel();
    this.mode = Locker.MODE_CHECK;
  }

  let checked = this.options.check.checked;

  let res = await this.record();

  if(res.err && res.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(res);
  }

  if(!res.err && password !== res.records){
    res.err = new Error(Locker.ERR_PASSWORD_MISMATCH)
  }

  checked.call(this, res);
  this.check(password);
  return Promise.resolve(res

设置密码的内部流程

async update(){
  if(this.mode !== Locker.MODE_UPDATE){
    await this.cancel();
    this.mode = Locker.MODE_UPDATE;
  }

  let beforeRepeat = this.options.update.beforeRepeat,
      afterRepeat = this.options.update.afterRepeat;

  let first = await this.record();

  if(first.err && first.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(first);
  }

  if(first.err){
    this.update();
    beforeRepeat.call(this, first);
    return Promise.resolve(first);
  }

  beforeRepeat.call(this, first);

  let second = await this.record();

  if(second.err && second.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(second);
  }

  if(!second.err && first.records !== second.records){
    second.err = new Error(Locker.ERR_PASSWORD_MISMATCH);
  }

  this.update();
  afterRepeat.call(this, second);
  return Promise.resolve(se

我们可以看到，有了 Recorder 之后，Locker 的验证和设置密码基本上就是顺着流程用 async/await 写下来就行了。

另外，我们还要注意一些细节问题。由于实际在手机上触屏时，如果上下拖动，浏览器的默认行为会导致页面上下移动，因此我们需要阻止 touchmove 的默认事件。

this.container.addEventListener('touchmove',
      evt => evt.preventDefault(), {passive: false});

touchmove 事件在 Chrome 下默认是一个 Passive Event，因此，我们 addEventListener 的时候需要传参 {passive: false}，否则就不能 preventDefault。

此外，因为我们的代码使用了 ES6+，所以需要引入 babel 编译，我们的组件也使用 webpack 进行打包，以便于使用者在浏览器中直接引入。

要点总结

今天，我和你一起完成了前几天留下的“手势密码”实战题。通过解决这几道题，我希望你能记住这三件事：

在设计 API 的时候思考真正的需求，判断什么该开放、什么该封装

做好技术调研和核心方案研究，选择合适的方案

着手优化和解决细节问题，要站在 API 使用者的角度思考

源码

GitHub 工程

用户故事 | 非前端开发，我为什么要学可视化？

你好，我是月影。专栏已经更新过半啦，首先我想感谢仍然坚持认真学习的你，很高兴见证你的成长。在这个过程中，我的课程对你有哪些帮助呢？你可以写在留言区分享给我。

今天，我想和你分享一篇罗同学的故事。罗同学每堂课都会非常认真地完成课后作业。在这里，我想对他说，很高兴能够看到你分享自己的学习故事，谢谢你能喜欢这个专栏。你是一名 Android 开发工程师，实际上我也写过 Android App，所以我们也算是半个同行。做技术的同学能够喜爱折腾技术是特别好的特质，尤其是在折腾技术的同时还能重视基础的积累，这就更难得了。这样的同学往往能成长到很高的高度，希望我的专栏能够让你在专业之路上走得更远。

好了，下面，我们一起来看看他的收获吧。

你好，我是罗乾林，是一名有着 5 年工作经验的 Android 应用开发工程师，坐标上海，很高兴能跟你分享我与可视化的故事。

讲起来还挺惭愧的，我的本职工作并不是前端开发，甚至我连 JavaScript 的语法都不怎么熟，但这并不影响我学习可视化专栏，反而除了图形学的知识以外，在阅读老师代码的过程中，我还学到了 JavaScript 的相关语法知识。因此，我希望结合我的经历，给同样非前端，正在学或者想学这门课的你一些帮助。

我为什么会学可视化？

首先，我想先说说，我为什么要学可视化，原因主要有两方面。

一方面，我的工作是开发 Android 应用，在需要绘图的时候，我通常会使用内部提供的 Canvas， 它底层使用的是 Skia 图形引擎，提供的绘图接口跟浏览器上的 Canvas 有些差别。这让我对原生的 Canvas 产生了好奇。

而且，平时我就是一个比较喜欢折腾其他技术的人，比如 MFC 绘图的 DC、QT 的 QPainter 、JavaFx 的 Canvas。这些环境都提供了一些类似的绘制基本图形的接口。但是，一直以来我只是在调用这些接口，并没有认真思考过这些绘图系统的核心是什么，对它们的理解也都不深入。

这就导致，我在绘制一个效果的时候，从一个提供了接口的绘图环境中，换到另一个没有提供接口的环境中就会不知所措。

另一方面，因为项目的原因我接触到了 OpenGL ES 绘图，而 OpenGL ES 只支持几种图元的绘制，很多图形的绘制都要自己实现。这里，我想先来简单说说这个项目。

这个项目简单来说就是开发一个自定义播放器，播放器运行在 Android 设备上。播放器从服务端获取布局文件，根据布局文件将界面划分为多个大小不等的区域，分别用来展示视频、图片、文本。其中，播放视频我们采用的方案是，将视频解码为一帧一帧的 RGB 数据并缩放到相应大小送入 Canvas 显示。

在开发过程中我发现，对一些稍大分辨率的视频文件，播放器播放起来非常卡顿。通过在网上查阅资料了解到，我可以将视频解码出 YUV 格式的数据直接送入 GPU 中展示，以此来提升播放性能。就在这个过程中，我接触到了 OpenGL ES。

刚接触一项新技术的时候，我们应该都是倾向于先写个“Hello World”找找感觉。于是，我就跟着网上的教程写了一个绘制三角形的 demo。写完后感觉整个人都不好了，因为绘制一个简单的三角形，我就写了快 90 行代码，中间涉及的 vertexbuffer 和 indexbuffer 更是看得人一头雾水，而且 OpenGL ES 提供的图元就只有点、线、三角形。这个时候，我就在想那要绘制一个圆该咋办呢？使用 OpenGL ES 完全没有我之前使用绘图系统提供的绘图接口丰富、易用。这更加深了我在可视化方面的困惑。

我与《跟月影学习可视化》专栏

因为我一直有用极客时间来学习，刚好在这个时候，我看到平台推出了“跟月影学可视化”这门课程，看了看介绍，发现就是在讲解图形学的知识，并且，OpenGL ES 和专栏中讲的 WebGL 几乎一样，这正是我想要的，所以我就立马买了下来。

开始学习专栏之后我发现，我前面遇到的这些问题，都是因为缺少图形学的基础知识。就像月影老师在第 5 讲所说的“如果我们手中只有解决具体问题的工具，没有统一的方法论，那我们也无法一劳永逸地解决问题的根本”。

比如说，我在初中的时候，就学了点构成线、线构成面、面构成体，这些最基本的数学原理却一直不能在实践中进行应用。课程中的数学基础篇让我重新认识了数学的强大，不局限于某一图形系统提供的 API，图形学的基础知识才是根本。扩展到其他的计算机知识都是这样，现在新技术层出不穷，让人眼花缭乱，而其最本质的知识都是不变的。

而且，随着学习的深入，我把之前有想过但并没有深入思考过的问题都发掘了出来，并且也都有了答案。比如说，判断点是否在区域内。我平时在绘图的时候，大部分是判断点是否在一些规则图形内部，即使碰到了一些不规则图形，也是使用现成的 API 判断，如 Android 提供了 Path、Region，浏览器 Canvas2D 的 isPointInPath 等。

接着，第 8 讲让我了解到，图形学领域有三角剖分这样成熟的技术来解决这类问题。第 14 讲和第 19 讲，又解开了我困惑已久的，如何使用着色器实现几何造型和动画的问题。

在平时的开发过程中，我也会简单使用仿射变换来做图形操作，适当地使用坐标变换，使我们在绘制一些图案时更加简单。而专栏中讲解的生成重复图案、分形图案及随机效果，更加让我体会到了数学知识的强大（离开学校后才意识到数学很有用）。

像学习其他软件编程的时候，我们在初期并不会觉得数学知识很重要，即使是做了几年的软件开发也未必会觉得数学知识在编程中帮助很大。而在图形学中很快就能让人意识到数学很重要，特别是线性代数，我们会经常使用矩阵变换、曲线方程、向量运算等数学原理，甚至还要用到物理学的知识，如实现动画涉及的缓动函数，实现自由落体等模拟现实世界的图形绘制。这也激发了我学习的兴趣，让我觉得这样的技术才是有门槛的。

关于学习专栏的一点建议

最后，我想给正在学习专栏的你一点建议，在绘图过程中有很多细节需要我们特别注意，如果只是听听老师的讲解，没有自己动手去尝试，那有很多细节就会留意不到，也就不会引起我们的重视。这门课程对我来说，学习起来确实不是很轻松，我相信也有很多人像我一样，即使我们现在学习起来有点困难，可只要我们把代码拉下来运行一下，调整下参数，再观察图形是否像我们预料的那样，也是能够加速对原理的理解，所以我的建议就是一定要多动手实践。

对底层技术的学习能让我们在技术方面更加地自由、自信，也是我们快速掌握新技术的内功。我认为计算机技术的核心是：操作系统、数据结构和算法、编译原理、计算机网络、计算机图形学。这也是我一直学习的方向。

结束语 | 可视化工程师的未来之路

你好，我是月影。

今天是可视化专栏的最后一节课，这里，我要恭喜你顺利毕业！

从课程上线到今天正式结束，我们一起走过了 112 天。但是，从课程开始筹备到今天，可远远不止这些日子。这几个月，我陪着你，从图形学基础学到了向量、参数方程，再到后来的视觉、性能和数据篇，看着同学们在留言区的进步，就算是点点滴滴的进步我都觉得很激动，因为我知道这很不容易。

对我来说，只要你通过这几个月的时间，收获了可视化相关的知识、技巧、思想以及编写代码的快乐，就是我交出的一份满意的答卷了。

但是，学习之路永远没有终点，借着这个难得的机会，我还想带你回顾一下整个专栏的学习过程，说说我在讲课过程中的那些顾虑，以及我认为你今后应该重点攻破的地方。专栏虽然结束了，但是未来的路，我依然与你同行！

近几年来，因为产品需求的驱动，可视化方向技术发展也非常迅速，不过在前端整体迅猛发展的大环境下，可视化方向依然属于一个比较小众的领域。我了解到的情况是，作为前端工程师，实际真正从事可视化领域并持续发展的人并不多，甚至可以说是远低于市场的需求的。因此高级和资深的可视化工程师，在行业里是非常有竞争力的。

那为什么优秀的可视化工程师这么少呢？实际上，阻挡前端工程师往可视化方向深入发展的技术门槛主要有两道，一是图形学，二是 WebGL。图形学主要是因为对使用者的数学基础要求比较高，而 WebGL 主要是因为它更偏底层，整体来说比 SVG 和 Canvas 等其他的图形系统更加复杂。因此，这两道门槛让许多前端工程师对可视化望而却步，不敢迈入这个领域。

为了破除这两道门槛，在这门课程里，我花了很多时间帮你系统地梳理了图形学以及图形学相关的数学知识，而且我也详细地为你讲解了 WebGL 的基础，我想你肯定忘不了那些丰富的实战例子和由浅入深的封装过程。通过这些，我其实就是想告诉你学习和使用 WebGL 也那么困难。

在克服了两道技术门槛之后，可视化其他方面的内容我们学习起来就相对容易了。从动画、滤镜、数据加载、渲染性能到图表库、绘图库，这些内容都没有脱离前端的范畴。至于数据处理，以及色彩和可用性原则，它们也与前端领域的内容一脉相承。你只要花点时间多积累些经验，就一定能学好。学好这些内容，对你前端技能的提升也会有很大帮助。

学习技术的过程，是从接纳和记忆知识开始的，但绝不仅仅是接纳和记忆知识，而是需要深入思考，自己总结和沉淀的。为了方便你快速吸收知识，我在每节课都做了内容梳理和总结，在每一个独立篇章末尾也列出了脑图，帮助你理解和记忆学过的内容。但实际上，这么做我还是有些顾虑的，因为这些内容都是我自己的沉淀和总结，并不能代替你的学习过程，如果你因此“偷懒”了，光记忆我整理的内容，省略了自己的沉淀和总结，是不可能真正融会贯通可视化领域的知识和技能的。

就像我说过的，如果你只是学会使用图表库的 API，那么你只是 API 工程师，并不能成为真正的可视化工程师，也不能做出真正出色的可视化产品。同样，如果你只是记忆了我教给你的这些知识，你也只不过成为了一个高级一点的 API 工程师，并不能真正成为独立创作成功作品的可视化大师。我的期望当然不是让你成为高级一点的 API 工程师，而是希望你在可视化的道路上越走越远。

除此以外，还有些难以用语言表达的东西，一些思考的方法和过程，其实我都把它们融入在课程内容的讲解中了，如果你从中已经得到了一些有用的启发，那么我会很高兴，如果你还没能领悟到更多的内容，你可以在一边工作实践中一边回顾和复习课程内容，我相信你会有更多的收获。如果在学习的过程中，你有任何疑问或者需要其他的帮助，你都可以在留言区与我交流，我非常愿意继续帮助你成长。

我们都知道，持续不断的学习需要强大的自我驱动力，但我想这个行业的光辉未来也能激励我们前行。

随着产品需求和技术的发展，可视化领域在未来一段时间会有更多激动人心的变化。比如说，5G 的普及就能让我们使到用更加复杂的渲染库，更加丰富的图像资源，而不用担心太大的包导致下载时间过长。再比如说，随着 AR/VR 的推广，未来我们可能会将可视化场景从普通的液晶屏幕扩展到虚拟现实中真正的 3D 场景里。

你可以想象一下，在未来可视化应用中，我们的用户可以带着 3D 眼镜，在一个与真实场景别无二致的虚拟空间里与各种数据和模型交互。这个时候，如果我们能在其中实现更加令人震撼的效果，会有多酷，是不是想想就非常激动？

最后，期待在未来科技中看到你的作品，也希望你成为一名优秀的可视化工程师。这门课到此就正式告一段落了，但生活还将继续，愿与你在可视化工程师的未来之路上相逢！

最后的最后，我还为你准备了一份毕业调查问卷，题目不多，希望你能花两分钟的时间填一下。一起走过了这些时间，期待听到你对我和这个课程的反馈和建议！