This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<audio id="audio" title="国庆策划 | 假期别闲着,一起来挑战“手势密码”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/cc/6503ec59e4e146f59d93cbde1d36d9cc.mp3"></audio>
你好,我是月影。
首先祝你中秋快乐在国庆、中秋的8天假期里我特意为你准备了一道动手实操题。这道题虽然不在我们的课程设计中但我把它特意安排在国庆假期就是希望你能有充足的时间来动手练习。在这道题里你需要自己思考怎么设计API、选择合适的技术方案以及着手去解决和优化一些细节性问题。
希望通过这道题能够帮助你进一步理解如何开发一个比较复杂的UI组件。这对于理解可视化也有借鉴作用因为一些复杂的可视化效果本身也类似于实现一个复杂的UI组件。
不过,在正式开始做题之前,我还有一个要求,我希望你能自己去思考解题方法,真正动手去实践,不要依赖网上的答案,这样才能真正找出自己的不足。
话不多说,我们直接进入实战吧!
在移动端设备上,“手势密码”是一个很常用的 UI 组件。一个手势密码的界面大致如下所示。用户用手指按顺序依次划过 9 个原点中的若干个(必须不少于 4 个点),如果划过的点的数量和顺序与之前用户设置的相同,那么当用户的手指离开屏幕时,判定为密码输入正确,否则密码错误。
<img src="https://static001.geekbang.org/resource/image/bc/a3/bc0eebaa18e6667f45fc43e8d5604fa3.jpeg" alt="">
要求:现在你需要实现一个移动网页,允许用户设置手势密码和验证手势密码。已设置的密码记录在本地 localStorage 中。接下来,我把要实现的界面原型和操作流程告诉你。
**第一步,设置密码。用户选择设置密码时,要提示用户输入手势密码。**
<img src="https://static001.geekbang.org/resource/image/cb/d0/cb4fba8a7cd29f8beb8c4111e958d2d0.jpeg" alt="">
**第二步,密码长度太短。如果用户输入的密码不足 5 个点,提示用户密码太短。**
<img src="https://static001.geekbang.org/resource/image/a4/4a/a4e8eea8e6bd28a291fc375ccd26884a.jpeg" alt="">
**第三步,设置成功一次密码后,提示用户再次输入密码。**
<img src="https://static001.geekbang.org/resource/image/dd/90/dd0e53a9fdf73de358e77af33c7c9390.jpeg" alt="">
**第四步,如果用户输入的两次密码不一致,提示用户重新开始设置密码,并重置。**
<img src="https://static001.geekbang.org/resource/image/60/ac/600c96162f0675bfc784d9b35d1404ac.jpeg" alt="">
**第五步,如果两次输入一致,密码设置成功,更新 localStorage。**
<img src="https://static001.geekbang.org/resource/image/b4/8e/b4dc1c6b7dcdf98103c22507761e2e8e.jpeg" alt="">
**第六步,验证密码的正确性。**切换单选框进入验证密码模式,将用户输入的密码与保存的密码相比较,如果不一致,则提示输入密码不正确,重置为等待用户输入。
<img src="https://static001.geekbang.org/resource/image/0b/e0/0beb0b75da9a3c3a6d1beecffba004e0.jpeg" alt="">
如果用户输入的密码与 localStorage 中保存的密码一致,则提示密码正确。
<img src="https://static001.geekbang.org/resource/image/e3/5c/e3fa6e124b73b9b0a94b123307c2f05c.jpeg" alt="">
最后,请你按照上面的需求实现这个网页,让它在手机上可用。你可以不用考虑古老机器的兼容性问题,只需要让最新的 Android 和 iPhone 可用就可以了。
完整的题目就是这样,希望你能又快又好地实现它。实现完成后,可以在留言区分享你的思路和代码,期待你的作品。

View File

@@ -0,0 +1,628 @@
<audio id="audio" title="国庆策划 | 快来看看怎么用原生JavaScript实现手势解锁组件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/5e/218617175b918812c5187c8c0c1de75e.mp3"></audio>
你好,我是月影。前几天,我给你出了一道实操题,不知道你完成得怎么样啦?
今天,我就给你一个[参考版本](https://github.com/akira-cn/handlock)。当然,并不是说这一版就最好,而是说,借助这一版的实现,我们就能知道当遇到这样比较复杂的 UI 需求时,我们应该怎样思考和实现。
<img src="https://static001.geekbang.org/resource/image/b1/b2/b1a40490690d3c0418842d86fc81b2b2.jpeg" alt="">
首先组件设计一般来说包括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 库](http://snapsvg.io/),实现起来和使用 Canvas 大同小异我就不详细来说了。但是SVG 的问题是移动端兼容性不如 DOM 和 Canvas 好,所以综合上面三者的情况,我最终选择使用 Canvas 来实现。
## 结构设计
使用 Canvas 实现的话, DOM 结构就比较简单了。为了实现响应式,我们需要实现一个自适应宽度的正方形容器,方法前面已经讲过了,然后我们在容器中创建 Canvas。
这里需要注意的一点是,我们应当把 Canvas 分层。这是因为 Canvas 的渲染机制里,要更新画布的内容,需要刷新要更新的区域重新绘制。因此我们有必要把频繁变化的内容和基本不变的内容分层管理,这样能显著提升性能。
在这里我把 UI 分别绘制在 3 个图层里,对应 3 个 Canvas。最上层只有随着手指头移动的那个线段中间是九个点最下层是已经绘制好的线。之所以这样分是因为随手指头移动的那条线需要不断刷新底下两层都不用频繁更新但是把连好的线放在最底层是因为我要做出圆点把线的一部分遮挡住的效果。
<img src="https://static001.geekbang.org/resource/image/cf/93/cf14330f6f0149252afb57ccb991a293.jpeg" alt="">
接着,我们确定圆点的位置。
<img src="https://static001.geekbang.org/resource/image/f7/bd/f731ffa24422655e218b7f362385f6bd.jpeg" alt="">
圆点的位置有两种定位法,第一种是九个九宫格,圆点在小九宫格的中心位置。认真的同学肯定已经发现了,在前面 DOM 方案里,我们就是采用这样的方式。这个时候,圆点的直径为 11.1%。第二种方式是用横竖三条线把宽高四等分,圆点在这些线的交点处。
在 Canvas 里我们采用第二种方法来确定圆点(代码里的 n = 3
```
let range = Math.round(width / (n + 1));
let circles = [];
//drawCircleCenters
for(let i = 1; i &lt;= n; i++){
for(let j = 1; j &lt;= 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。将组件功能分解为更加底层的组件是一种简化组件设计的常用模式。
<img src="https://static001.geekbang.org/resource/image/53/df/53c4bb35522954095ca736bdf6d86edf.jpeg" alt="">
我们抽取出底层的 Recorder让 Locker 继承 RecorderRecorder 负责记录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” 就是如下选择图形:
<img src="https://static001.geekbang.org/resource/image/82/b8/82500410b843734363a9c49d6f3b5fb8.jpeg" alt="">
为了让 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 &amp;&amp; !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 =&gt; 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 &lt;= n; i++){
for(let j = 1; j &lt;= 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', ()=&gt;{
this.clearPath();
});
let records = [];
let handler = evt =&gt; {
let {clientX, clientY} = evt.changedTouches[0],
{bgColor, focusColor, innerRadius, outerRadius, touchRadius} = options,
touchPoint = getCanvasPoint(moveCanvas, clientX, clientY);
for(let i = 0; i &lt; this.circles.length; i++){
let point = this.circles[i],
x0 = point.x,
y0 = point.y;
if(distance(point, touchPoint) &lt; 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) =&gt; {
recordingTask.cancel = (res = {}) =&gt; {
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 =&gt; {
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 &lt; options.minPoints){
err = new Error(Recorder.ERR_NOT_ENOUGH_POINTS);
}
//这里可以选择一些复杂的编码方式,本例子用最简单的直接把坐标转成字符串
let res = {err, records: records.map(o =&gt; o.pos.join('')).join('')};
resolve(res);
this.recordingTask = null;
};
document.addEventListener('touchend', done);
});
recordingTask.promise = promise;
this.recordingTask
```
这里有几个公开的方法分别是ecorder 负责记录绘制结果, clearPath 负责在画布上清除上一次记录的结果cancel 负责终止记录过程,这是为后续流程准备的。
## 流程设计
接下来,我们基于 Recorder 来设计设置和验证密码的流程:
首先是验证密码的流程:
<img src="https://static001.geekbang.org/resource/image/c1/3c/c1e94603bfb1d26a0b354377095b6f3c.jpeg" alt="">
其次是设置密码的流程:
<img src="https://static001.geekbang.org/resource/image/da/3a/da4509d380c71e30bdd03ec27c000e3a.jpeg" alt="">
有了前面异步 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 &amp;&amp; res.err.message === Locker.ERR_USER_CANCELED){
return Promise.resolve(res);
}
if(!res.err &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; second.err.message === Locker.ERR_USER_CANCELED){
return Promise.resolve(second);
}
if(!second.err &amp;&amp; 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 =&gt; evt.preventDefault(), {passive: false});
```
touchmove 事件在 Chrome 下默认是一个 [Passive Event](https://dom.spec.whatwg.org/#in-passive-listener-flag)因此我们addEventListener 的时候需要传参 {passive: false},否则就不能 preventDefault。
此外,因为我们的代码使用了 ES6+,所以需要引入 babel 编译,我们的组件也使用 webpack 进行打包,以便于使用者在浏览器中直接引入。
## 要点总结
今天,我和你一起完成了前几天留下的“手势密码”实战题。通过解决这几道题,我希望你能记住这三件事:
1. 在设计 API 的时候思考真正的需求,判断什么该开放、什么该封装
1. 做好技术调研和核心方案研究,选择合适的方案
1. 着手优化和解决细节问题要站在API使用者的角度思考
## 源码
[GitHub 工程](https://github.com/akira-cn/handlock)

View File

@@ -0,0 +1,205 @@
<audio id="audio" title="01 | 浏览器中实现可视化的四种方式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/a1/dfc675382eef454f1dc612d18ffdeaa1.mp3"></audio>
你好,我是月影。
上一节课我们了解了什么是可视化。可视化用一句话来说本质上就是将数据信息组织起来后以图形的方式呈现出来。在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代码。
<img src="https://static001.geekbang.org/resource/image/68/31/68d43be360923664f2a3d8c2c65fbc31.jpg" alt="" title="用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);
}
```
而要实现饼图,我们可以使用圆锥渐变,方法也很简单,你直接看代码就可以理解。
<img src="https://static001.geekbang.org/resource/image/58/6d/58c6ea6ffce4e47446c0c9636d47226d.jpg" alt="" title="使用圆锥渐变绘制的饼图">
```
.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这样的高级属性我们还能实现更复杂的图表比如用不同的颜色表示两个不同折线的面积。
<img src="https://static001.geekbang.org/resource/image/cc/c9/cc4d0f6d9260d508758c8043a14ea1c9.jpg" alt="" title="折线图和面积图">
实际上很多常见的可视化图表我们都可以用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和CSSCanvas2D和WebGL更适合去做可视化这一领域的绘图工作。它们的绘图API能够直接操作绘图上下文一般不涉及引擎的其他部分在重绘图像时也不会发生重新解析文档和构建结构的过程开销要小很多。
<img src="https://static001.geekbang.org/resource/image/d4/9d/d49d2fb673a7fb9f8de329c12fab009d.jpg" alt="" title="图形系统与浏览器渲染引擎工作对比">
## 方式二SVG
在介绍Canvas2D和WebGL之前我们先来说一说SVG。现代浏览器支持SVGScalable Vector Graphics可缩放矢量图SVG是一种基于 XML 语法的图像格式可以用图片img元素的src属性加载。而且浏览器更强大的是它还可以内嵌SVG标签并且像操作普通的HTML元素一样利用DOM API操作SVG元素。甚至CSS也可以作用于内嵌的SVG元素。
比如上面的柱状图如果用SVG实现的话我们可以用如下所示的代码来实现
```
&lt;!--
dataset = {
total: [25, 26, 40, 45, 68],
current: [15, 11, 17, 25, 37],
}
--&gt;
&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;120px&quot; height=&quot;240px&quot; viewBox=&quot;0 0 60 100&quot;&gt;
&lt;g transform=&quot;translate(0, 100) scale(1, -1)&quot;&gt;
&lt;g&gt;
&lt;rect x=&quot;1&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;25&quot; fill=&quot;#37c&quot;/&gt;
&lt;rect x=&quot;13&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;26&quot; fill=&quot;#37c&quot;/&gt;
&lt;rect x=&quot;25&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;40&quot; fill=&quot;#37c&quot;/&gt;
&lt;rect x=&quot;37&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;45&quot; fill=&quot;#37c&quot;/&gt;
&lt;rect x=&quot;49&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;68&quot; fill=&quot;#37c&quot;/&gt;
&lt;/g&gt;
&lt;g&gt;
&lt;rect x=&quot;1&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;15&quot; fill=&quot;#3c7&quot;/&gt;
&lt;rect x=&quot;13&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;11&quot; fill=&quot;#3c7&quot;/&gt;
&lt;rect x=&quot;25&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;17&quot; fill=&quot;#3c7&quot;/&gt;
&lt;rect x=&quot;37&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;25&quot; fill=&quot;#3c7&quot;/&gt;
&lt;rect x=&quot;49&quot; y=&quot;0&quot; width=&quot;10&quot; height=&quot;37&quot; fill=&quot;#3c7&quot;/&gt;
&lt;/g&gt;
&lt;/g&gt;
&lt;/svg&gt;
```
从上面的SVG代码中我们可以一目了然地看出数据total和current分别对应SVG中两个g元素下的rect元素的高度。也就是说元素的属性和数值可以直接对应起来。而CSS代码并不能直观体现出数据的数值需要进行CSS规则转换。具体如下图所示
<img src="https://static001.geekbang.org/resource/image/c9/62/c9716f8c3768a2384c3baf5f8ec87362.jpg" alt="">
在上面这段SVG代码中g表示分组rect表示绘制一个矩形元素。除了rect外SVG还提供了丰富的图形元素可以绘制矩形、圆弧、椭圆、多边形和贝塞尔曲线等等。由于SVG比较复杂我们会在第4节课专门介绍如何用SVG绘制可视化图表。在那之前你也可以通过[MDN官方文档](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial)来学习更多的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场景。
知道了这些优缺点,在实际面对可视化需求的时候,我们就可以根据具体项目的特点来选择合适的方案实现可视化了。那具体来说,我们应该怎么选择呢?我这里梳理了一张技术方案的选择图,你可以看一看。
<img src="https://static001.geekbang.org/resource/image/3b/6f/3bf11fcf520504a4e342dd335698c76f.jpg" alt="">
## 小试牛刀
我们在文中实现了SVG版本的柱状图你可以尝试用SVG实现同HTML/CSS版本一样的饼图、折线图和面积图体会一下使用SVG实现和HTML/CSS实现的不同点。
另外下一节课我们会介绍Canvas2D绘制可视化图表你可以提前预习一下试一试能否用Canvas2D来绘制文中的柱状图。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!

View File

@@ -0,0 +1,364 @@
<audio id="audio" title="02 | 指令式绘图系统如何用Canvas绘制层次关系图" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/96/09058a18fbecb3d2d69c14466ec8ae96.mp3"></audio>
你好,我是月影。
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元素。
```
&lt;body&gt;
&lt;canvas width=&quot;512&quot; height=&quot;512&quot;&gt;&lt;/canvas&gt;
&lt;/body&gt;
```
这里有一点需要特别注意Canvas元素上的width和height属性不等同于Canvas元素的CSS样式的属性。这是因为CSS属性中的宽高影响Canvas在页面上呈现的大小而HTML属性中的宽高则决定了Canvas的坐标系。为了区分它们我们称Canvas的HTML属性宽高为**画布宽高**CSS样式宽高为**样式宽高**。
在实际绘制的时候如果我们不设置Canvas元素的样式那么Canvas元素的画布宽高就会等于它的样式宽高的像素值也就是512px。
而如果这个时候我们通过CSS设置其他的值指定了它的样式宽高。比如说我们将样式宽高设置成256px那么它实际的画布宽高就是样式宽高的两倍了。代码和效果如下
```
canvas {
width: 256px;
height: 256px;
}
```
<img src="https://static001.geekbang.org/resource/image/01/f1/01b8b4f686e776b379a2d8141ac13ff1.jpg" alt="" title="将canvas的样式宽高设为256px画布宽高设为512px">
因为画布宽高决定了可视区域的坐标范围所以Canvas将画布宽高和样式宽高分开的做法能更方便地适配不同的显示设备。
比如我们要在画布宽高为500`*`500的Canvas画布上绘制一个居中显示的100`*`100宽高的正方形。我们只要将它的坐标设置在 x = 200, y = 200 处即可。这样不论这个Canvas以多大的尺寸显示在各种设备上我们的代码都不需要修改。否则如果Canvas的坐标范围画布宽高跟着样式宽高变化那么当屏幕尺寸改变的时候我们就要重新计算需要绘制的图形的所有坐标这对于我们来说将会是一场“灾难”。
<img src="https://static001.geekbang.org/resource/image/5e/45/5e5d78e4fd8c42948b6ae68e79e69e45.jpg" alt="" title="在画布宽高为500*500的Canvas上居中绘制一个100*100的正方形">
### 2. Canvas 的坐标系
好了Canvas画布已经设置好了接下来我们来了解一下Canvas的坐标系。
Canvas的坐标系和浏览器窗口的坐标系类似它们都默认左上角为坐标原点x轴水平向右y轴垂直向下。那在我们设置好的画布宽高为512`*`512的Canvas画布中它的左上角坐标值为0,0右下角坐标值为512,512 。这意味着坐标0,0512,512之间的所有图形都会被浏览器渲染到画布上。
[<img src="https://static001.geekbang.org/resource/image/6c/f1/6cb9eca4e9c8c64a5e07694f7f6428f1.jpg" alt="" title="canvas坐标系">](https://www.jianshu.com/p/ef8244cb7ec4)
注意上图中这个坐标系的y轴向下意味着这个坐标系和笛卡尔坐标系不同它们的y轴是相反的。那在实际应用的时候如果我们想绘制一个向右上平抛小球的动画它的抛物线轨迹在Canvas上绘制出来的方向就是向下凹的。
[<img src="https://static001.geekbang.org/resource/image/f0/2e/f025b55279ea553387a058ab9184012e.jpeg" alt="" title="Canvas坐标系">](https://www.jianshu.com/p/ef8244cb7ec4)
另外,如果我们再考虑旋转或者三维运动,这个坐标系就会变成“左手系”。而左手系的平面法向量的方向和旋转方向,和我们熟悉的右手系相反。如果你现在还不能完全理解它们的区别,那也没关系,在实际应用的时候,我会再讲的,这里你只需要有一个大体印象就可以了。
[<img src="https://static001.geekbang.org/resource/image/2c/0d/2c01d99b4a3c9a46ba446cc208a6560d.jpeg" alt="" title="左手系和右手系">](https://zhuanlan.zhihu.com/p/64707259)
### 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();
```
<img src="https://static001.geekbang.org/resource/image/1c/44/1c13a4yyd5f8284edb29038178eb9144.jpeg" alt="">
但是,看到上面这张图,我们发现了一个问题:正方形并没有居于画布的正中心。这是为什么呢?
你可以回想一下我们刚才的操作在绘制正方形的时候我们将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]);
```
在上面的代码中,我们给画布设置了一个平移,平移距离为矩形宽高的一半,这样它的中心点就是画布的中心了。
<img src="https://static001.geekbang.org/resource/image/3a/56/3ab2306311ba2382301ae9b654e62856.jpeg" alt="">
既然这两种方法都能将图形绘制到画布的中心,那我们该怎么选择呢?其实,我们可以从这两种方式各自的优缺点入手,下面我就具体来说一说。
第一种方式很简单,它直接改变了我们要绘制的图形顶点的坐标位置,但如果我们绘制的不是矩形,而是很多顶点的多边形,我们就需要在绘图前重新计算出每个顶点的位置,这会非常麻烦。
第二种方式是对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个步骤
1. 获取Canvas对象通过getContext(2d)得到2D上下文
1. 设置绘图状态比如填充颜色fillStyle平移变换translate等等
1. 调用 beginPath 指令开始绘制图形;
1. 调用绘图指令比如rect表示绘制矩形
1. 调用fill指令将绘制内容真正输出到画布上。
除此之外Canvas上下文还提供了更多丰富的API可以用来绘制直线、多边形、弧线、圆、椭圆、扇形和贝塞尔曲线等等这里我们不一一介绍了。在之后的课程中我们会详细讲解如何利用这些API来绘制复杂的几何图形。如果你还想了解更多关于Canvas的API相关的知识还可以去阅读[MDN文档](https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial)。
## 如何用Canvas 绘制层次关系图?
知道了Canvas的基本用法之后接下来我们就可以利用Canvas给一组城市数据绘制一个层次关系图了。也就是在一组给出的层次结构数据中体现出同属于一个省的城市。
在操作之前呢,我们先引入一个概念层次结构数据 Hierarchy Data它是可视化领域的专业术语用来表示能够体现层次结构的信息例如城市与省与国家。一般来说层次结构数据用层次关系图表来呈现。
其中城市数据是一组JSON格式的数据如下所示。
```
{
&quot;name&quot;:&quot;中国&quot;,
&quot;children&quot;:
[
{
&quot;name&quot;:&quot;浙江&quot; ,
&quot;children&quot;:
[
{&quot;name&quot;:&quot;杭州&quot; },
{&quot;name&quot;:&quot;宁波&quot; },
{&quot;name&quot;:&quot;温州&quot; },
{&quot;name&quot;:&quot;绍兴&quot; }
]
},
{
&quot;name&quot;:&quot;广西&quot; ,
&quot;children&quot;:
[
{&quot;name&quot;:&quot;桂林&quot;},
{&quot;name&quot;:&quot;南宁&quot;},
...
}
]
}
```
我们要绘制的层次关系图效果如下:
<img src="https://static001.geekbang.org/resource/image/07/bf/072dbcd9607dd7feafa47e34f97784bf.jpeg" alt="">
知道了要求之后我们应该怎么做呢首先我们要从数据源获取JSON数据。
```
const dataSource = 'https://s5.ssl.qhres.com/static/b0695e2dd30daa64.json';
(async function () {
const data = await (await fetch(dataSource)).json();
}());
```
这份JSON数据中只有“城市&gt;省份&gt;中国”这样的层级数据,我们要将它与绘图指令建立联系。建立联系指的是,我们要把数据的层级、位置和要绘制的圆的半径、位置一一对应起来。
换句话说我们要把数据转换成图形信息这个步骤需要数学计算。不过我们可以直接使用d3-hierarchy[d3-hierarchy](https://github.com/d3/d3-hierarchy)这个工具库转换数据。
```
(async function () {
const data = await (await fetch(dataSource)).json();
const regions = d3.hierarchy(data)
.sum(d =&gt; 1)
.sort((a, b) =&gt; 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 &lt; children.length; i++) {
draw(ctx, 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点需要我们注意
1. 在HTML中建立画布时我们要分别设置画布宽高和样式宽高
1. 在建立坐标系时我们要注意canvas的坐标系和笛卡尔坐标系在y轴上是相反的
1. 如果要把图形绘制在画布中心我们不能直接让x、y的坐标等于画布中心坐标而是要让图形中心和画布中心的位置重叠。这个操作我们可以通过计算顶点坐标或者 平移变换来实现。
接着我们讲了利用Canvas展示数据的层级关系。在这个过程中我们应当先处理数据将数据内容与绘图指令建立映射关系然后遍历数据通过映射关系将代表数据内容的参数传给绘图指令最后将图形绘制到Canvas上。
另外我们还讲了Canvas的优缺点。在实际实现可视化业务的时候Canvas的简单易操作和高效的渲染能力是它的优势但是它的缺点是不能方便地控制它内部的图形元素。
最后我还有一点想要补充一下。我们今天绘制的图形都是静态的如果要使用Canvas绘制动态的图形也很简单我们可以通过clearRect指令将之前的图形擦除再把新的图形绘制上去即可。在后续课程中我们有专门的章节来介绍动画。
## 小试牛刀
最后呢,我为你准备了两道课后题,试着动手操作一下吧!
1. 这节课我们介绍了用Canvas绘制矩形和圆弧实际上Canvas还有更多的绘图指令来绘制不同类型的几何图形。你可以试着修改一下前面显示正方形的例子在画布的中心位置显示一个三角形、椭圆或五角星。
1. Canvas的缺点是不能方便地控制它内部的图形元素但这不代表它完全不能控制。你可以尝试给我们前面绘制的层次关系图增加鼠标的交互让鼠标指针在移动到某个城市所属的圆的时候这个圆显示不同的颜色提示你可以获取鼠标坐标然后用这个坐标到圆心的距离来判断
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[用Canvas绘制层次关系图的完整代码.](https://github.com/akira-cn/graphics/tree/master/canvas/hierarchy)
## 推荐阅读
[1] [MDN官方文档.](https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial)

View File

@@ -0,0 +1,255 @@
<audio id="audio" title="03 | 声明式图形系统如何用SVG图形元素绘制可视化图表" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/a5/4256090856681648991eb448e236d6a5.mp3"></audio>
你好我是月影。今天我们来讲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声明的例子。
```
&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; version=&quot;1.1&quot;&gt;
&lt;circle cx=&quot;100&quot; cy=&quot;50&quot; r=&quot;40&quot; stroke=&quot;black&quot;
stroke-width=&quot;2&quot; fill=&quot;orange&quot; /&gt;
&lt;/svg&gt;
```
在上面的代码中svg元素是SVG的根元素属性xmlns是xml的名字空间。那第一行代码就表示svg元素的xmlns属性值是"[http://www.w3.org/2000/svg](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文档中就可以在浏览器中绘制出一个带黑框的橙色圆形了。
<img src="https://static001.geekbang.org/resource/image/c0/7f/c0617044406c562834c9e3db9d6d877f.jpg" alt="" title="黑色外框的圆形">
现在我们已经知道了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 &lt; 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绘制的层次关系图没什么差别纸上得来终觉浅你可以自己动手实现一下这样理解得会更深刻。
<img src="https://static001.geekbang.org/resource/image/07/bf/072dbcd9607dd7feafa47e34f97784bf.jpeg" alt="" title="层次关系图">
## 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) =&gt; {
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 &amp;&amp; target.parentNode.nodeName === 'g') {
const parentName = target.parentNode.getAttribute('data-name');
return `${parentName}-${name}`;
}
return name;
}
```
最后我们就可以在mousemove事件中更新titleEl的文本内容了。
```
svgroot.addEventListener('mousemove', (evt) =&gt; {
...
titleEl.textContent = getTitle(target);
...
});
```
这样,我们就实现了给层次关系图增加鼠标控制的功能,最终的效果如下图所示,完整的代码我放在[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/svg)了,你可以自己去查看。
<img src="https://static001.geekbang.org/resource/image/9c/2f/9c807df0cb2b4afyye6be3345a9c1a2f.gif" alt="">
其实我们上面讲的鼠标控制功能就是一个简单的用户交互功能。总结来说利用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树渲染成为性能瓶颈。
## 小试牛刀
<li>
DOM操作SVG元素和操作普通的HTML元素几乎没有差别所以CSS也同样可以作用于SVG元素。那你可以尝试使用CSS来设置这节课我们实现的层级关系图里circle的背景色和文字属性。接着你也可以进一步想一想这样做有什么好处
</li>
<li>
因为SVG可以作为一种图像格式使用所以我们可以将生成的SVG作为图像然后绘制到Canvas上。那如果我们先用SVG生成层级关系图再用Canvas来完成绘制的话和我们单独使用它们来绘图有什么不同为什么
</li>
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[用SVG绘制层次关系图和给层次关系图增加鼠标控制的完整代码.](https://github.com/akira-cn/graphics/tree/master/svg)

View File

@@ -0,0 +1,357 @@
<audio id="audio" title="04 | GPU与渲染管线如何用WebGL绘制最简单的几何图形" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/86/edff7bb4e68f0196ab771eyye70b9586.mp3"></audio>
你好我是月影。今天我们要讲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图形处理单元负责图形计算。
<img src="https://static001.geekbang.org/resource/image/b5/56/b5e4f37e1c4fbyy6a2ea10624d143356.jpg" alt="">
知道了这些概念,我带你来看一个典型的绘图过程,帮你来明晰一下这些概念的实际用途。
首先数据经过CPU处理成为具有特定结构的几何信息。然后这些信息会被送到GPU中进行处理。在GPU中要经过两个步骤生成光栅信息。这些光栅信息会输出到帧缓存中最后渲染到屏幕上。
<img src="https://static001.geekbang.org/resource/image/9f/46/9f7d76cc9126036ef966dc236df01c46.jpeg" alt="" title="图形数据经过GPU处理最终输出到屏幕上">
这个绘图过程是现代计算机中任意一种图形系统处理图形的通用过程。它主要做了两件事一是对给定的数据结合绘图的场景要素例如相机、光源、遮挡物体等等进行计算最终将图形变为屏幕空间的2D坐标。二是为屏幕空间的每个像素点进行着色把最终完成的图形输出到显示设备上。这整个过程是一步一步进行的前一步的输出就是后一步的输入所以我们也把这个过程叫做**渲染管线**RenderPipelines
在这个过程中CPU与GPU是最核心的两个处理单元它们参与了计算的过程。CPU我相信你已经比较熟悉了但是GPU又是什么呢别着急听我慢慢和你讲。
## GPU是什么
CPU和GPU都属于处理单元但是结构不同。形象点来说CPU就像个大的工业管道等待处理的任务就像是依次通过这个管道的货物。一条CPU流水线串行处理这些任务的速度取决于CPU管道的处理能力。
实际上一个计算机系统会有很多条CPU流水线而且任何一个任务都可以随机地通过任意一个流水线这样计算机就能够并行处理多个任务了。这样的一条流水线就是我们常说的**线程**Thread
[<img src="https://static001.geekbang.org/resource/image/1e/80/1e6479ef37138f051b7a6e5de6977580.jpeg" alt="" title="CPU">](https://thebookofshaders.com/)
这样的结构用来处理大型任务是足够的,但是要处理图像应用就不太合适了。这是因为,处理图像应用,实际上就是在处理计算图片上的每一个像素点的颜色和其他信息。每处理一个像素点就相当于完成了一个简单的任务,而一个图片应用又是由成千上万个像素点组成的,所以,我们需要在同一时间处理成千上万个小任务。
要处理这么多的小任务比起使用若干个强大的CPU使用更小、更多的处理单元是一种更好的处理方式。而GPU就是这样的处理单元。
[<img src="https://static001.geekbang.org/resource/image/1a/e7/1ab1116e3742611f5cb26c942d67d5e7.jpeg" alt="" title="GPU">](https://thebookofshaders.com/)
GPU是由大量的小型处理单元构成的它可能远远没有CPU那么强大但胜在数量众多可以保证每个单元处理一个简单的任务。即使我们要处理一张800 * 600大小的图片GPU也可以保证这48万个像素点分别对应一个小单元这样我们就可以**同时**对每个像素点进行计算了。
那GPU究竟是怎么完成像素点计算的呢这就必须要和WebGL的绘图过程结合起来说了。
## 如何用WebGL绘制三角形
浏览器提供的WebGL API是OpenGL ES的JavaScript绑定版本它赋予了开发者操作GPU的能力。这一特点也让WebGL的绘图方式和其他图形系统的“开箱即用”直接调用绘图指令或者创建图形元素就可以完成绘图的绘图方式完全不同甚至要复杂得多。我们可以总结为以下5个步骤
1. 创建WebGL上下文
1. 创建WebGL程序WebGL Program
1. 将数据存入缓冲区
1. 将缓冲区数据读取到GPU
1. 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从顶点着色器和图元提取像素点给片元着色器执行代码的过程就是我们前面说的生成光栅信息的过程我们也叫它光栅化过程。所以**片元着色器的作用,就是处理光栅化后的像素信息。**
这么说可能比较抽象,我 来举个例子。我们可以将图元设为线段,那么片元着色器就会处理顶点之间的线段上的像素点信息,这样画出来的图形就是空心的。而如果我们把图元设为三角形,那么片元着色器就会处理三角形内部的所有像素点,这样画出来的图形就是实心的。
<img src="https://static001.geekbang.org/resource/image/6c/6e/6c4390eb21e653274db092a9ba71946e.jpg" alt="">
这里你要注意一点因为图元是WebGL可以直接处理的图形单元所以其他非图元的图形最终必须要转换为图元才可以被WebGL处理。举个例子如果我们要绘制实心的四边形我们就需要将四边形拆分成两个三角形再交给WebGL分别绘制出来。
好了,那让我们回到片元着色器对像素点着色的过程。你还要注意,这个过程是并行的。也就是说,**无论有多少个像素点,片元着色器都可以同时处理。**这也是片元着色器一大特点。
以上就是片元着色器的作用和使用特点了关于顶点着色器的作用我们一会儿再说。说了这么多你可别忘了创建着色器的目的是为了创建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选择启用这个WebGLProgram对象。这样当我们绘制图形时GPU就会执行我们通过WebGLProgram设定的 两个shader程序了。
```
gl.useProgram(program);
```
好了现在我们已经创建并完成WebGL程序的配置。接下来 我们只要将三角形的数据存入缓冲区也就能将这些数据送入GPU了。那实现这一步之前呢我们先来认识一下WebGL的坐标系。
### 步骤三:将数据存入缓冲区
我们要知道WebGL的坐标系是一个三维空间坐标系坐标原点是0,0,0。其中x轴朝右y轴朝上z轴朝外。这是一个右手坐标系。
<img src="https://static001.geekbang.org/resource/image/yy/b1/yy3e873beb7743096e3cc7b641e718b1.jpeg" alt="">
假设,我们要在这个坐标系上显示一个顶点坐标分别是(-1, -11, -10, 1的三角形如下图所示。因为这个三角形是二维的所以我们可以直接忽略z轴。下面我们来一起绘图。
<img src="https://static001.geekbang.org/resource/image/83/c3/8311b485131497ce59cd1600b9a7f7c3.jpeg" alt="">
**首先,我们要定义这个三角形的三个顶点**。WebGL使用的数据需要用类型数组定义默认格式是Float32Array。Float32Array是JavaScript的一种类型化数组TypedArrayJavaScript通常用类型化数组来处理二进制缓冲区。
因为平时我们在Web前端开发中使用到类型化数组的机会并不多你可能还不大熟悉不过没关系类型化数组的使用并不复杂定义三角形顶点的过程你直接看我下面给出的代码就能理解。不过如果你之前完全没有接触过它我还是建议你阅读[MDN文档](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypedArray),去详细了解一下类型化数组的使用方法。
```
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画布上画出了一个红色三角形。
<img src="https://static001.geekbang.org/resource/image/cc/61/ccdd298c45f80a9a00d23082cf637d61.jpeg" alt="">
为什么是红色三角形呢?因为我们在片元着色器中定义了像素点的颜色,代码如下:
```
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的值这样我们就能得到下面这个三角形
<img src="https://static001.geekbang.org/resource/image/5c/21/5c4c718eca069be33d8a1d5d1eb77821.jpeg" alt="">
我们可以看到这个三角形是一个颜色均匀线性渐变的三角形它的三个顶点的色值就是我们通过顶点着色器来设置的。而且你会发现中间像素点的颜色是均匀过渡的。这就是因为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完整的绘图过程实在比较复杂为了帮助你理解我总结一个流程图供你参考。
[<img src="https://static001.geekbang.org/resource/image/d3/30/d31e6c50b55872f81aa70625538fb930.jpg" alt="" title="WebGL绘图流程">](https://juejin.im/post/5e7a042e6fb9a07cb96b1627)
那到这里可视化的四个图形系统我们就介绍完了。但是好戏才刚刚开始哦在后续的文章中我们会围绕着这四个图形系统尤其是Canvas2D和WebGL逐渐深入来实现更多有趣的图形。
## 小试牛刀
<li>
WebGL通过顶点和图元来绘制图形我们在上面的例子中调用gl.TRIANGLES 绘制出了实心的三角形。如果要绘制空心三角形,我们又应该怎么做呢?有哪些图元类型可以帮助我们完成这个绘制?
</li>
<li>
三角形是最简单的几何图形如果我们要绘制其他的几何图形我们可以通过用多个三角形拼接来实现。试着用WebGL绘制正四边形、正五边形和正六角星吧
</li>
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这一节课分享给你的朋友,我们下节课再见!
## 源码
[WebGL绘制三角形示例代码.](https://github.com/akira-cn/graphics/tree/master/webgl)
## 推荐阅读
[1] [类型化数组 MDN 文档.](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypedArray)<br>
[2] [WebGL 的 MDN 文档.](https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API)

View File

@@ -0,0 +1,304 @@
<audio id="audio" title="28 | Canvas、SVG与WebGL在性能上的优势与劣势" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/27/a9e59409a006e3d5a01d8f8cd43f8027.mp3"></audio>
你好,我是月影。
性能优化,一直以来都是前端开发的难点。
我们知道前端性能是一块比较复杂的内容由许多因素决定比如网页内容和资源文件的大小、请求数、域名、服务器配置、CDN等等。如果你能把性能优化好就能极大地增强用户体验。
在可视化领域也一样,可视化因为要突出数据表达的内容,经常需要设计一些有视觉震撼力的图形效果,比如,复杂的粒子效果和大量元素的动态效果。想要实现这些效果,图形系统的渲染性能就必须非常好,能够在用户的浏览器上稳定流畅地渲染出想要的视觉效果。
那么针对可视化渲染,我们都要解决哪些性能问题呢?
## 可视化渲染的性能问题有哪些?
由于前端的可视化也是在Web上展现的因此像网页大小这些因素也会影响它的性能。而且无论是可视化还是普通Web前端针对这些因素进行性能优化的原理和手段都一样。
所以我今天想和你聊的是可视化方面特殊的性能问题。它们在我们熟悉的Web前端工作中并不常见通常只在可视化中绘制复杂图形的时候我们才需要重点考虑。这些问题大体上可以分为两类一类是**渲染效率问题,<strong>另一类是**计算问题</strong>
**我们先来看它们的定义,渲染效率问题指的是图形系统在绘图部分所花费的时间,而计算问题则是指绘图之外的其他处理所花费的时间,包括图形数据的计算、正常的程序逻辑处理等等**
我们知道在浏览器上渲染动画每一秒钟最高达到60帧左右。也就是说我们可以在1秒钟内完成60次图像的绘制那么完成一次图像绘制的时间就是1000/601秒=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 &lt; count; i++) {
drawCircle(context, radius);
}
}
requestAnimationFrame(function update() {
ctx.clearRect(0, 0, WIDTH, HEIGHT);
draw(ctx);
requestAnimationFrame(update);
});
```
如上面代码所示我们在Canvas上每一帧绘制500个半径为10的小圆效果如下
<img src="https://static001.geekbang.org/resource/image/b2/e3/b278a4f98413b9029dfa914ab4b88be3.jpg" alt="" title="500个小球半径10">
注意为了方便查看帧率的变化我们在浏览器中开启了帧率检测。Chrome开发者工具自带这个功能我们在开发者工具的Rendering标签页中勾选FPS Meter就可以开启这个功能查看帧率了。
我们现在看到即使每帧渲染500个位置和颜色都随机的小圆形Canvas渲染的帧率依然能达到60fps。
接着我们增加小球的数量把它增加到1000个。
<img src="https://static001.geekbang.org/resource/image/8c/f9/8cbe753219d08a1b84753fe5f89518f9.jpg" alt="" title="1000个小球半径10">
这时你可以看到因为小球数量增加一倍所以帧率掉到了50fps左右现在下降得还不算太多。而如果我们把小球的数量设置成3000你就能看到明显的差别了。
那如果我们把小球的数量保持在500把半径增大到很大如200也会看到帧率有明显下降。
<img src="https://static001.geekbang.org/resource/image/ee/81/ee69d57ce2b1214f55650f8c3f8d9681.jpg" alt="" title="500个小球半径200">
但是单从上图的实验来看图形大小对帧率的影响也不是很大。因为我们把小球的半径增加了20倍帧率也就下降到33fps。当然这也是因为画圆比较简单如果我们绘制的图形更复杂一些那么大小的影响会相对显著一些。
通过这个实验我们能得出影响Canvas的渲染性能的主要因素有两点一是**绘制图形的数量**,二是**绘制图形的大小。**这正好验证了我们开头的结论。
总的来说Canvas2D绘制图形的性能还是比较高的。在普通的个人电脑上我们要绘制的图形不太大时只要不超过500个都可以达到60fps1000个左右其实也能达到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 &lt; 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 &lt; COUNT; i++) {
drawCircle(circles[i]);
}
requestAnimationFrame(draw);
}
draw();
```
在我的电脑上一台普通的MacBook Pro内存8GB独立显卡绘制了500个半径为10的小球时SVG的帧率接近60fps会比Canvas稍慢但是差别不是太大。
<img src="https://static001.geekbang.org/resource/image/01/fa/01f6d0d37a0aeabd8449dyyecfc4e2fa.jpg" alt="" title="SVG绘制500个小球半径10">
当我们将小球数量增加到1000个时SVG的帧率就要略差一些大概45fps左右。
<img src="https://static001.geekbang.org/resource/image/4e/b6/4efb6930462da79f36e913546f5eb1b6.jpg" alt="" title="SVG绘制1000个小球半径10">
乍一看似乎SVG和Canvas2D的性能差别也不是很大。不过随着小球数量的增加两者的差别会越来越大。比如说当我们将小球的个数增加到3000个左右的时候Canvas2D渲染的帧率依然保持在30fps以上而SVG渲染帧率大约只有15fps差距会特别明显。
之所以在小球个数较多的时候二者差距很大因为SVG是浏览器DOM来渲染的元素个数越多消耗就越大。
如果我们保证小球个数在一个小数值然后增大每个小球的半径那么与Canvas一样SVG的渲染效率也会明显下降。
<img src="https://static001.geekbang.org/resource/image/19/ea/19d991c1ee547d1f98fe2f504eaba1ea.jpg" alt="" title="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 &lt; 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 &lt; 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 &lt; 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的帧率。
<img src="https://static001.geekbang.org/resource/image/84/6f/84be3a259d9d7dc0572cf8044029536f.jpg" alt="" title="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的渲染性能有所影响不过没有前面三点的影响大。
## 小试牛刀
<li>
刚才我们用SVG、Canvas和WebGL分别实现了随机小球由此比较了三种图形系统的性能。但是我们并没说HTML/CSS你能用HTML/CSS来实现这个例子吗用HTML/CSS来实现在性能方面与SVG、Canvas和WebGL有什么区别呢从中你能得出影响HTML/CSS渲染性能的要素吗
</li>
<li>
在WebGL的例子中我们采用了批量绘制的技术。实际上我们也可以不采用这个技术给每个小球生成一个mesh对象然后让Ogl来渲染。你可以试着用Ogl不采用批量渲染来实现随机小球然后对比它们之间的渲染方案得出性能方面的差异吗?
</li>
## 源码
[课程中详细示例代码](https://github.com/akira-cn/graphics/tree/master/performance-basic)

View File

@@ -0,0 +1,415 @@
<audio id="audio" title="29 | 怎么给Canvas绘制加速" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/33/ed06f10b056dca8af21f2f7dd8494433.mp3"></audio>
你好,我是月影。
上节课,我们从宏观上了解了各个图形系统在性能方面的优劣,以及影响性能的要素。实际上,想要解决性能问题,我们就必须要知道真正消耗性能的点,从而结合项目需求进行有针对的处理,否则性能优化就是纸上谈兵、空中楼阁。
所以这节课我们就深入讨论一下影响Canvas绘图性能的因素一起来分析几个不同类型的Canvas项目找到的性能瓶颈以及对应的解决办法从而学会对大部分Canvas项目进行性能优化。
我们知道Canvas是指令式绘图系统它有状态设置指令、绘图指令以及真正的绘图方法fill和stroke等各类API。通常情况下利用Canvas绘图我们要先调用状态设置指令设置绘图状态然后用绘图指令决定要绘制的图形最后调用真正的fill()或stroke()方法将内容输出到画布上。
那结合上节课的实验我们知道影响Canvas性能的两大因素分别是图形的数量和图形的大小。它们都会直接影响绘图指令一个决定了绘图指令的多少另一个决定了绘图指令的执行时间。通常来说绘图指令越多、执行时间越长渲染效率就越低性能也就越差。
因此我们想要对Canvas性能进行优化最重要的就是优化渲染效率。常用的手段有5种分别是优化Canvas指令、使用缓存、分层渲染、局部重绘和优化滤镜。此外还有一种手段叫做**多线程渲染**,是用来优化非渲染的计算和交互方面导致的性能问题。
首先我们来说说优化Canvas指令。
## 手段一优化Canvas指令
刚刚我们说了Canvas执行的绘图指令越多性能的消耗就越大。那如果希望Canvas绘图达到更好的性能我们要尽可能减少绘图指令的数量。这就是“优化Canvas指令”要做的事情。
那具体怎么做呢?我们看一个例子。
假设我们要在一个600 X 600的画布上实现一些位置随机的多边形并且不断刷新这些图形的形状和位置效果如下
<img src="https://static001.geekbang.org/resource/image/4e/68/4e226d7385aa48c23049b451a4525468.gif" alt="">
结合我们之前学过的知识这个效果其实并不难实现可以分为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 &lt; 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 &lt; 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 &lt; 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 &lt; 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 &gt; 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的性能瓶颈主要在绘图指令方面如果我们能将图形缓存下来保存到离屏的Canvasoffscreen Canvas然后在绘制的时候作为图像来渲染那我们就可以将绘制顶点的绘图指令变成直接通过drawImage指令来绘制图像而且也不需要fill()方法来填充图形,这样性能就会有大幅度的提升。
具体的做法,是我们先实现一个创建缓存的函数。代码如下:
```
function createCache() {
const ret = [];
for(let i = 0; i &lt; 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 &gt; 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 &lt; 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中。
我们还是来看一个例子。
<img src="https://static001.geekbang.org/resource/image/4f/e9/4f0c60c3810d372d41ee14d90efb23e9.gif" alt="">
假设,我们要实现一个如上图的效果。这个效果的特点是,画面上有一个飞机在运动,运动的物体比较少,而其他静止不动的图形很多(如背景中的上千个三角形)。
在绘制的时候我们如果将运动的物体和其他物体都绘制在同一个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 &lt; count; i++) {
drawRandomTriangle(p, context);
}
}
function loadImage(src) {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) =&gt; {
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的全局区域而是根据运动的元素的范围来清空部分区域。**在很大一部分可视化大屏项目中,我们不会让整个屏幕的所有元素都不断改变,而是只有一些固定的区域改变,所以我们直接刷新那部分区域,重绘区域中的元素就可以了。
<img src="https://static001.geekbang.org/resource/image/ff/16/ff209e67ac077c1c43782e0291500e16.jpeg" alt="" title="大屏的动态区与静态区">
如上图所示一个可视化大屏只有2块动态区域需要不断重绘那我们用Canvas上下文的**clearRect方法控制要刷新的动态区域**,只对这些区域进行擦除然后重绘。
要注意的是动态区重绘的时候区域内的静态元素也需要跟着重绘。如果有静态元素跨越了动态和静态区域范围那在重绘时我们自然不希望破坏了静态区的图形。这时候我们可以使用Canvas上下文的clip方法它是一种特殊的绘图指令可以设定一个绘图区让图形的绘制限制在这个绘图区内部。这样的话图形中超过clip范围的部分浏览器就不会把它渲染到Canvas上。
这种固定区域的局部重绘使用起来不难,但有时候我们不知道具体的动态区域究竟多大。这个时候,我们可以使用动态计算要重绘区域的技术,它也被称为**脏区检测**。它的基本原理是根据动态元素的**包围盒**,动态算出需要重绘的范围。
那什么是包围盒呢?
我们知道,多边形由顶点构成,包围盒就是指能包含多边形所有顶点,并且与坐标轴平行的最小矩形。
[<img src="https://static001.geekbang.org/resource/image/f2/55/f26c205843dc9479c9ce9ebd7d762855.jpeg" alt="" title="多边形包围盒">](https://www.pngsucai.com/png/950314.html)
在Canvas平面直角坐标系下求包围盒并不复杂只要分别找到所有顶点坐标中x的最大、最小值xmin和xmax以及y的最大、最小值ymin和ymax那么包围盒就是矩形[(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)]。
对所有的动态元素计算出包围盒我们就能知道局部刷新的范围了。不过在实际操作的时候我们经常会遇到各种复杂的细节问题需要解决。因为涉及的细节比较多我没法全都讲到所以如果你遇到了问题可以看看蚂蚁金服AntV团队的[Canvas局部渲染优化总结](https://juejin.im/post/5e7c13b351882535ea43e1a2)这篇文章。
## 手段五:优化滤镜
实际上分层渲染和局部重绘解决的都是图形重绘的问题。那除了重绘影响渲染效率的还有Canvas滤镜。
我们知道滤镜是一种对图形像素进行处理的方法Canvas支持许多常用的滤镜。不过Canvas渲染滤镜的性能开销比较大。到底有多大呢我们还是用前面绘制随机图形的例子来体验一下。
这次我们用缓存优化版本的代码这一版代码的性能最高。在绘制前我们给Canvas设置一个blur滤镜。代码如下
```
ctx.filter = 'blur(5px)';
```
这样呢我们让Canvas绘制出来的图形有了模糊的效果。但是这么设置了之后你会发现原本60fps的帧率直接掉到2fps画面看上去一顿一顿的卡得惨不忍睹。这就是因为滤镜对渲染性能的开销实在太大了。
<img src="https://static001.geekbang.org/resource/image/1f/7b/1f236cbdb1eb16a3faf9f7e6c18fa27b.gif" alt="">
那这种情况下其实我们也有优化手段。针对这个场我们实际上是对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 &lt; 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();
```
<img src="https://static001.geekbang.org/resource/image/63/5b/63dcf4f363561cb745yy7075b418985b.gif" alt="">
当然这种优化滤镜的方式只有当我们要对画布上绘制的所有图形都采用同一种滤镜的时候才有效。不过如果有部分图形采用相同的滤镜而且它们是连续绘制的我们也可以采用类似的办法把这部分图形绘制到离屏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 &lt; 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) =&gt; {
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仓库](https://github.com/akira-cn/graphics/tree/master/performance_canvas)
## 推荐阅读
[1] [AntV Canvas 局部渲染总结](https://juejin.im/post/5e7c13b351882535ea43e1a2)<br>
[2] [Speed up Your Canvas Operations with a Web WorkerWebWorker和OffscreenCanvas使用参考文档](https://developers.google.com/web/updates/2018/08/offscreen-canvas)

View File

@@ -0,0 +1,488 @@
<audio id="audio" title="30怎么给WebGL绘制加速" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/12/d51b62b019a47160fyya1e4f35039312.mp3"></audio>
你好我是月影。这节课我们一起来讨论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 &lt; 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个随机三角形的功能效果如下
<img src="https://static001.geekbang.org/resource/image/3a/d3/3a67da60549daebb2e460d2dc267efd3.gif" alt="">
你会发现这样实现的图形性能很一般因为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 &lt; 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中使用多实例绘制会稍微复杂一点我们一般不会这么做但如果你想要尝试一下可以参考[这篇文章](https://www.jianshu.com/p/d40a8b38adfe)。
使用多实例绘制的代码如下:
```
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 &lt; edges; i++) {
const angle = a0 + i * delta;
positions.push([x + radius * Math.sin(angle), y + radius * Math.cos(angle)]);
if(i &gt; 0 &amp;&amp; i &lt; 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 &lt; count; i++) {
const edges = 3 + Math.floor(4 * Math.random());
const delta = 2 * Math.PI / edges;
for(let j = 0; j &lt; edges; j++) {
const angle = j * delta;
positions.set([0.1 * Math.sin(angle), 0.1 * Math.cos(angle), i], (offset + j) * 3);
if(j &gt; 0 &amp;&amp; j &lt; 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。
<img src="https://static001.geekbang.org/resource/image/31/d5/31c369d5441acb07c3724b1d0daa91d5.jpg" alt="">
批量渲染几乎是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() &gt; 0.5) {
do something
} else {
do somthing else
}
```
我们都知道如果if语句中的条件值为true那么第一个分支被执行否则第二个分支被执行这两个分支是不能同时被执行的。
但如果是Shader中的代码情况就完全不同了。
```
if(random(st) &gt; 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中拿到几何体位置和颜色的需求吗如果可以就快把你的解决方案写好分享出来吧。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码](https://github.com/akira-cn/graphics/tree/master/performance-webgl)
## 推荐阅读
[WebGL2系列之实例数组(Instanced Arrays)](https://www.jianshu.com/p/d40a8b38adfe)

View File

@@ -0,0 +1,299 @@
<audio id="audio" title="05 | 如何用向量和坐标系描述点和线段?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/f9/4d122bf8176b4debc4d6210856537df9.mp3"></audio>
你好,我是月影。
为什么你做了很多可视化项目,解决了一个、两个、三个甚至多个不同类型的图表展现之后,还是不能系统地提升自己的能力,在下次面对新的项目时依然会有各种难以克服的困难?这是因为你陷入了细节里。
什么是细节简单来说细节就是各种纯粹的图形学问题。在可视化项目里我们需要描述很多的图形而描述图形的顶点、边、线、面、体和其他各种信息有很多不同的方法。并且如果我们使用不同的绘图系统每个绘图系统又可能有独特的方式或者特定的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。
尽管这四个坐标系在原点位置、坐标轴方向、坐标范围上有所区别,但都是**直角坐标系**,所以它们都满足直角坐标系的特性:不管原点和轴的方向怎么变,用同样的方法绘制几何图形,它们的形状和相对位置都不变。
<img src="https://static001.geekbang.org/resource/image/5e/89/5e3bc7cd089e2e28c527b57a1df5cb89.jpeg" alt="">
为了方便处理图形我们经常需要对坐标系进行转换。转换坐标系可以说是一个非常基础且重要的操作了。正因为这四个坐标系都是直角坐标系所以它们可以很方便地相互转化。其中HTML、SVG和Canvas都提供了transform的API能够帮助我们很方便地转换坐标系。而WebGL本身不提供tranform的API但我们可以在shader里做矩阵运算来实现坐标转换WebGL的问题我们在后续课程会有专门讨论今天我们先来说说其他三种。那接下来我们就以Canvas为例来看看用transform API怎样进行坐标转换。
## 如何用Canvas实现坐标系转换
假设我们要在宽512 * 高256的一个Canvas画布上实现如下的视觉效果。其中山的高度是100底边200两座山的中心位置到中线的距离都是80太阳的圆心高度是150。
当然,在不转换坐标系的情况下,我们也可以把图形绘制出来,但是要经过顶点换算,下面我们就来说一说这个过程。
<img src="https://static001.geekbang.org/resource/image/a8/09/a8ec91897b2ede72d5c48d4d6b2d5409.jpeg" alt="">
首先因为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、436176 - 100 =76、176 + 100=276、336 - 100=236、 336 + 100=436
<img src="https://static001.geekbang.org/resource/image/55/29/552676f6f0268d2091b838e268651929.jpeg" alt="">
计算出这些坐标之后,我们很容易就可以将这个图画出来了。不过,为了增加一些趣味性,我们用一个[Rough.js](https://github.com/pshihn/rough)的库绘制一个手绘风格的图像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',
});
```
最终,我们绘制出的图形效果如下所示:
<img src="https://static001.geekbang.org/resource/image/cd/cb/cddabd7aeca8e5yy0c22c85879f5dccb.jpeg" alt="">
到这里,我们通过简单的计算就绘制出了这一组图形。但你也能够想到,如果每次绘制都要花费时间在坐标换算上,这会非常不方便。所以,为了解决这个问题,我们可以采用坐标系变换来代替坐标换算。
这里我们给Canvas的2D上下文设置一下transform变换。我们经常会用到两个变换translate和scale。
首先我们通过translate变换将Canvas画布的坐标原点从左上角(0, 0)点移动至(256, 256)位置即画布的底边上的中点位置。接着以移动了原点后新的坐标为参照通过scale(1, -1)将y轴向下的部分即y&gt;0的部分沿x轴翻转180度这样坐标系就变成以画布底边中点为原点x轴向右y轴向上的坐标系了。
<img src="https://static001.geekbang.org/resource/image/8a/de/8a1f3ed166942736206124aba16965de.jpeg" alt="" title="坐标系">
执行了这个坐标变换,也就是让坐标系原点在中间之后,我们就可以更方便、直观地计算出几个图形元素的坐标了。
两个山顶的坐标就是 (-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坐标值。
<img src="https://static001.geekbang.org/resource/image/0d/58/0de1596f2df5002c3a8b26723f0f0558.jpeg" alt="">
假设现在这个平面直角坐标系上有一个向量v。向量v有两个含义一是可以表示该坐标系下位于(x, y)处的一个点;二是可以表示从原点(0,0)到坐标(x,y)的一根线段。
接下来,为了方便你理解,我们先来回顾一下关于向量的数学知识。
**首先,向量和标量一样可以进行数学运算。**举个例子现在有两个向量v<sub>1</sub>和v<sub>2</sub>如果让它们相加其结果相当于将v<sub>1</sub>向量的终点x<sub>1</sub>, y<sub>1</sub>沿着v<sub>2</sub>向量的方向移动一段距离这段距离等于v<sub>2</sub>向量的长度。这样我们就可以在平面上得到一个新的点x<sub>1</sub> + x<sub>2</sub>, y<sub>1</sub> + y<sub>2</sub>),一条新的线段[(0, 0), (x<sub>1</sub> + x<sub>2</sub>, y<sub>1</sub> + y<sub>2</sub>)],以及一段折线:[(0, 0), (x<sub>1</sub>, y<sub>1</sub>) , (x<sub>1</sub> + x<sub>2</sub>, y<sub>1</sub> + y<sub>2</sub>)]。
<img src="https://static001.geekbang.org/resource/image/8e/29/8ebb3963e385ba9fda2dab46d7277e29.jpeg" alt="">
**其次,一个向量包含有长度和方向信息**。它的长度可以用向量的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);
```
这个推论意味着一个重要的事实:我们可以很简单地构造出一个绘图向量。也就是说,如果我们希望以点(x<sub>0</sub>, y<sub>0</sub>)为起点沿着某个方向画一段长度为length的线段我们只需要构造出如下的一个向量就可以了。
<img src="https://static001.geekbang.org/resource/image/7c/a3/7cf68477844ee77a31163008d2bb39a3.jpeg" alt="">
这里的α是与x轴的夹角v是一个单位向量它的长度为1。然后我们把向量(x<sub>0</sub>, y<sub>0</sub>)与这个向量v<sub>1</sub>相加,得到的就是这条线段的终点。这么讲还是比较抽象,我们看一个例子。
## 实战演练:用向量绘制一棵树
我们用前面学到的向量知识来绘制一棵随机生成的树,想要生成的效果如下:
<img src="https://static001.geekbang.org/resource/image/6y/f4/6yydf8017e95529yybb987d97e9yy9f4.jpeg" alt="">
我们还是用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上下文
- v<sub>0</sub>是起始向量
- length是当前树枝的长度
- thickness是当前树枝的粗细
- dir是当前树枝的方向用与x轴的夹角表示单位是弧度。
- bias是一个随机偏向因子用来让树枝的朝向有一定的随机性
因为v<sub>0</sub>是树枝的起点坐标,那根据前面向量计算的原理,我们创建一个单位向量(1, 0)它是一个朝向x轴长度为1的向量。然后我们旋转dir弧度再乘以树枝长度length。这样我们就能计算出树枝的终点坐标了。代码如下
```
const v = new Vector2D(1, 0).rotate(dir).scale(length);
const v1 = v0.copy().add(v);
```
向量的旋转是向量的一种常见操作,对于二维空间来说,向量的旋转可以定义成如下方法(这里我们省略了数学推导过程,有兴趣的同学可以去看一下[数学原理](https://zhuanlan.zhihu.com/p/98007510))。这个方法我们后面还会经常用到,你先记一下,后续我们讲到仿射变换的时候,会有更详细的解释。
```
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 &gt; 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);
}
```
<img src="https://static001.geekbang.org/resource/image/1f/y1/1f95a7d1e6ecf30c7db0ef0afc0f7yy1.jpeg" alt="">
这样,我们得到的就是一棵形状规律的树。
接着我们修改代码,加入随机因子,让迭代生成的新树枝有一个随机的偏转角度。
```
if(thickness &gt; 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);
}
```
这样,我们就可以得到一棵随机的树。
<img src="https://static001.geekbang.org/resource/image/53/7f/5350becdbb756ce4dae1289b7beba37f.jpeg" alt="">
最后,为了美观,我们再随机绘制一些花瓣上去,你也可以尝试绘制其他的图案到这棵树上。
```
if(thickness &lt; 5 &amp;&amp; Math.random() &lt; 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仓库](https://github.com/akira-cn/graphics/tree/master/vector_tree)你可以研究一下。这里面最关键的一步就是前面的向量操作为了实现向量的rotate、scale、add等方法我封装了一个简单的库Vector2d.js你也可以在[代码仓库](https://github.com/akira-cn/graphics/blob/master/common/lib/vector2d.js)中找到它。
## 向量运算的意义
实际上,在我们的可视化项目里,直接使用向量的加法、旋转和乘法来构造线段绘制图形的情形并不多。这是因为,在一般情况下,数据在传给前端的时候就已经计算好了,我们只需要拿到数据点的信息,根据坐标变换进行映射,然后直接用映射后的点来绘制图形即可。
既然这样,为什么我们在这里又要强调向量操作的重要性呢?虽然我们很少直接使用向量构造线段来完成绘图,但是向量运算的意义并不仅仅只是用来算点的位置和构造线段,这只是最初级的用法。我们要记住,**可视化呈现依赖于计算机图形学,而向量运算是整个计算机图形学的数学基础。**
而且,在向量运算中,除了加法表示移动点和绘制线段外,向量的点乘、叉乘运算也有特殊的意义。课后我会给你出一道有挑战性的思考题 ,让你能更深入地理解向量运算的现实意义,在下一节课里我会给你答案。
## 要点总结
这一节课, 我们以Canvas为例学习了坐标变换以及用向量描述点和线段的原理和方法。
一般来说采用平面直角坐标系绘图的时候对坐标进行平移等线性变换并不会改变坐标系中图形的基本形状和相对位置因此我们可以利用坐标变换让我们的绘图变得更加容易。Canvas坐标变换经常会用到translate和scale这两个变换它们的操作和原理都很简单我们根据实际需求来设置就好了。
在平面直角坐标系中,我们可以定义向量来绘图。向量可以表示绘图空间中的一个点,或者连接原点的一条线段。两个向量相加,结果相当于将被加向量的终点沿着加数向量的方向移动一段距离,移动的距离等于加数向量的长度。利用向量的这个特性,我们就能以某个点为起点,朝任意方向绘制线段,从而绘制各种较复杂的几何图形了。
## 小试牛刀
1. 我们已经知道如何用向量来定义一个线段你知道如何判断两个线段的位置关系吗假设有两个线段l<sub>1</sub>和l<sub>2</sub>,已知它们的起点和终点分别是[(x<sub>10</sub>, y<sub>10</sub>),(x<sub>11</sub>, y<sub>11</sub>)]、[(x<sub>20</sub>, y<sub>20</sub>),(x<sub>21</sub>, y<sub>21</sub>)],你能判断它们的关系吗(小提示:两个线段之间的关系有**平行、垂直**或既不平行又不垂直)?
1. 已知线段[(x<sub>0</sub>, y<sub>0</sub>)、(x<sub>1</sub>, y<sub>1</sub>)],以及一个点(x<sub>2</sub>, y<sub>2</sub>),怎么求点到线段的距离?
1. 一个平面上放置了一个扫描器方向延y轴方向该坐标系y轴向上扫描器的视角是60度。假设它可以扫描到无限远的地方那对于平面上给定的任意一个点(x,y),我们该如何判断这个点是否处于扫描范围内呢?
<img src="https://static001.geekbang.org/resource/image/89/64/8961491152b0fe953826d59d687a0b64.jpeg" alt="">
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[1][绘制随机树的源代码](https://github.com/akira-cn/graphics/tree/master/vector_tree)<br>
[2][坐标变换的源代码](https://github.com/akira-cn/graphics/tree/master/coordinates)
## 推荐阅读
[1] [二维旋转矩阵与向量旋转推荐文档](https://zhuanlan.zhihu.com/p/98007510)<br>
[2] 一个有趣的绘图库:[Rough.js](https://github.com/pshihn/rough)<br>
[3] [Vector2d.js模块文档](https://github.com/akira-cn/graphics/blob/master/common/lib/vector2d.js)

View File

@@ -0,0 +1,123 @@
<audio id="audio" title="06 | 可视化中你必须要掌握的向量乘法知识" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/54/f33ea06925ab306654336497a499bb54.mp3"></audio>
你好,我是月影。
上一节课我们学习了Canvas实现坐标系转换的方法以及利用向量描述点和线段来绘制基本图形的方法。接下来我们继续学习和向量有关的绘图知识教你用数学语言描述曲线。
不过,在讨论如何描述曲线之前,还有一些关于向量乘法的前置知识需要你掌握。虽然它们和如何描述曲线并没有直接的关系,但是使用它们可以计算曲线的控制点、判断曲线的方向以及对曲线进行变换。
因此向量的乘法在可视化中也是非常重要并且实用的内容。比如上节课的思考题3给你一个任意点让你判断这个点在不在这个扫描器范围内。这道题可以用我们前面学过的知识解决但用向量乘法可以更轻松解决。接下来就让我们从这一道题开始今天的学习吧
<img src="https://static001.geekbang.org/resource/image/61/55/61240301b570ff20e0fdff2d2910f955.jpg" alt="">
如果利用我们前面学过的知识来解题,你可能是直接使用向量的方向定义来做的,代码如下所示。
```
v.dir = function() {return Math.atan2(this.y, this.x)}
```
没错这道题我们可以使用向量的方向来解。因为这里的dir是由向量与x轴夹角决定的所以判断点是否在扫描器范围内我们只需要计算点坐标对应的向量的dir值是否在扫描器的范围内就可以了。代码如下
```
const isInRange = v0.dir &gt; Math.PI / 3 &amp;&amp; v0.dir &lt; 2 * Math.PI / 3;
```
这是一个很简单、直观的解法但是它不够完美因为这个判断和扫描器的方向有关。什么意思呢从上面的图中你可以看到现在它正对着y轴正方向所以角度在π/3和2π/3之间。但如果将它的方向旋转或者允许它朝向任意的方向我们就必须要修改对应的角度值了。这个时候就会非常麻烦。
因此,我们会使用一个更通用的解法,也就是利用向量的乘法来解。那具体怎么做呢?别着急,我先带你来复习一下我们高中学过的向量乘法的知识,如果你记得不是特别清楚,正好可以借着这个机会来加深印象。
我们知道,向量乘法有两种,一种是点乘,一种是叉乘,它们有着不同的几何和物理含义。下面,我们一一来看。
## 向量的点乘
首先,我们来看向量的点乘。
假设现在有两个N维向量a和ba = [a<sub>1</sub>, a<sub>2</sub>, ...a<sub>n</sub>]b = [b<sub>1</sub>, b<sub>2</sub>, ...b<sub>n</sub>],那向量的点积代码如下:
```
a•b = a1*b1 + a2*b2 + ... + an*bn
```
在N维线性空间中a、b向量点积的几何含义是a向量乘以b向量在a向量上的投影分量。它的物理含义相当于a力作用于物体产生b位移所做的功。点积公式如下图所示
<img src="https://static001.geekbang.org/resource/image/08/36/08ed8e6ded30ae53d8d3900e7f8bee36.jpg" alt="">
好了现在你已经知道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组成的平行四边形的面积**。
<img src="https://static001.geekbang.org/resource/image/2a/92/2ace54dafe5dae80e783a7fd7b38e392.png" alt="">
那叉乘在数学上该怎么计算呢假设现在有两个三维向量a(x<sub>1</sub>, y<sub>1</sub>, z<sub>1</sub>)和b(x<sub>2</sub>, y<sub>2</sub>, z<sub>2</sub>)那么a与b的叉积可以表示为一个如下图的行列式
<img src="https://static001.geekbang.org/resource/image/72/ca/72227ffca461a60a1e79c0f54b6777ca.jpg" alt="">
其中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叉积的方向这个方向是垂直纸面向外即朝向我们。因此右手系中向量叉乘的方向就是右手拇指的方向那左手系中向量叉乘的方向自然就是左手拇指的方向了。
<img src="https://static001.geekbang.org/resource/image/e1/89/e1a3da7d12e40b7acfa46ba4293d2b89.jpg" alt="">
在二维空间里由于z的值为0因此我们得到的向量a X b的数值就等于x<sub>1</sub> * y<sub>2</sub> - x<sub>2</sub> * y<sub>1</sub>。那它的物理意义是什么呢?二维空间中向量叉乘的物理意义就是**a和b的力矩**力矩你可以理解为一个物体在力的作用下绕着一个轴转动的趋向。它是一个向量等于力臂L和力F的叉乘。这个概念你记住就好了我们今天不会用到后面用到的时候我会再详细来讲
还记得上一节课的思考题2求点到线段的距离吗在了解了向量叉积的几何意义之后我们通过向量叉积得到平行四边形面积再除以底边长就能得到点到向量所在直线的距离了。是不是很简单
同样向量叉积也可以解决思考题3。那具体该怎么做呢
首先对于任意一点v<sub>0</sub>来说,我们都要先将它**归一化**。简单来说归一化就是让向量v<sub>0</sub>除以它的长度或者说是模。归一化后的向量方向不变长度为1。归一化是向量运算中一个非常重要的操作用处也非常多。比如说在向量乘法里如果a、b都是长度为1的归一化向量那么|a X b| 的结果就是a、b夹角的**正弦值**,而|a • b|的结果就是a、b夹角的**余弦值。**这个特性在图形学里用处非常大,你一定要记住它。
好了再说回来我们把归一化的向量a叉乘扫描器中线上的v(0,1)由于扫描器关于y轴对称所以扫描器边缘与y轴的夹角是正负30度。那么在与单位向量求叉积的时候就会出现2种情况
1. 点在扫描范围内如向量a就一定满足 |a X v| &lt;= ||a||v|sin(30°)| = |sin(30°)| = 0.5
1. 点不在扫描范围内如向量b就一定满足|b X v| &gt; ||b||v|sin(30°)| = |sin(30°)| = 0.5。
<img src="https://static001.geekbang.org/resource/image/9e/4a/9ea951c875b5dc3bc3169dda68fea94a.jpg" alt="">
因此只要任意一点所在的向量与单位向量的叉积结果的绝对值不大于0.5即sin30°就说明这个点在扫描范围内。所以我们可以用如下代码来判断
```
const isInRange = Math.abs(new Vec2(0, 1).cross(v0.normalize())) &lt;= 0.5; // v0.normalize()即将v0归一化
```
好了,关于向量乘法的内容,我们就全部复习完了,相信你已经很好地掌握它们了。不过,我还想再多说几句 ,对于图形学来说,向量运算是基础中的基础,非常重要。所以,我们不仅要熟练掌握,还要学会用向量的思路去解决问题。只有这样,你才能学好图形学,从而成为优秀的可视化工程师。
## 要点总结
这一节课,我们学习了向量的乘法,包括点乘与叉乘。
其中点乘的几何意义是向量a与它在向量b所在的轴的投影向量的乘积物理意义是力向量产生的位移向量所做的功。叉乘的几何意义是向量a和向量b构成的平行四边形的面积物理意义是力产生的力矩。
最后,我们还要记住,把向量归一化以后,我们就可以通过向量的点乘与叉乘快速求出向量夹角的正弦和余弦值。
## 小试牛刀
让我们延续上一节课的第2道思考题。已知平面上有一点P它的坐标是(x<sub>0</sub>, y<sub>0</sub>)还有一条直线直线上有两个点Q(x<sub>1</sub>, y<sub>1</sub>)和R(x<sub>2</sub>, y<sub>2</sub>)。你能求出点P到直线的距离以及点P到线段QR的距离吗这里面P、Q、R这三个值可以是任意的。你可以要试着用一段JavaScript代码把计算的过程写出来最好还能把直线、点和距离在Canvas上给直观地绘制出来。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!

View File

@@ -0,0 +1,359 @@
<audio id="audio" title="07 | 如何用向量和参数方程描述曲线?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/7f/6d02b64abf66844600991a1178236d7f.mp3"></audio>
你好,我是月影。
曲线是图形系统的基本元素之一,它可以构成几何图形的边,也可以描述点和几何体的运动轨迹,还可以控制像素属性的变化。不论我们用什么图形系统绘图,图形的呈现都离不开曲线。因此,对于可视化而言,掌握如何描述曲线是非常重要的。
今天,我们就来学习两种常见的描述曲线的方法,也就是用向量和参数方程来描述曲线。
## 如何用向量描述曲线?
我们先来说第一种方法,用向量来描述曲线。
我们知道曲线是可以用折线来模拟的。因此我们第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 &lt; 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仓库](https://github.com/akira-cn/graphics/tree/master/vector_draw)。
<img src="https://static001.geekbang.org/resource/image/e6/59/e62865e98dd038c6da690961fe722c59.jpeg" alt="">
从上面的例子中可以看出当多边形的边数非常多的时候这个图形就会接近圆。所以只要利用regularShape函数将多边形的边数设置得很大我们就可以绘制出圆形了。不过这个做法虽然能够绘制出圆这样的曲线但它还有一些缺点。
首先regularShape定义边数、起点、一条边的长度这就和我们通常的使用习惯也就是定义边数、中心和半径不符。如果我们按照现在这种定义方式绘图是很难精确对应到图形的位置和大小的。那你可能要说了不是还可以换算吗确实可以但是计算的过程比较繁琐也很容易出错。
其次regularShape可以画圆改进一下也可以画圆弧但是对于椭圆、抛物线、贝塞尔曲线等其他曲线的绘制就无能为力了。
那么,为了更简单地绘制出更多的曲线样式,我们需要用更好的模型来描述。
## 如何用参数方程描述曲线?
接下来我们就来讨论用参数方程描述曲线的方法。通过这个方法我们不仅可以描述常见的圆、椭圆、抛物线、正余弦等曲线还能描述更具有一般性的曲线也就是没有被数学公式预设好的曲线比如贝塞尔曲线或者CatmullRom曲线等等。
说到参数方程,接下来我在每次用它来画图之前,还是会先带你一起回顾相关的数学知识,这样对你后面的学习也会很方便。那我们先从最简单的曲线,也就是圆形开始,来看看它是如何用参数方程绘制的。
### 1. 画圆
首先圆可以用一组参数方程来定义。如下所示的参数方程定义了一个圆心在x<sub>0</sub>,y<sub>0</sub>半径为r的圆。
<img src="https://static001.geekbang.org/resource/image/67/09/679bb841b70f7c7bae35d84c98a86b09.jpeg" alt="">
知道了方程下面我们来说一下计算圆顶点的方法。首先我们实现一个画圆弧的函数arc代码如下所示。我们设置圆心为x<sub>0</sub>、y<sub>0</sub>半径为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 &lt;= 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时这个方程是就圆的方程式。所以圆实际上就是椭圆的特例。
<img src="https://static001.geekbang.org/resource/image/c4/4b/c47446d2df11fba932267665e65d254b.jpeg" alt="">
再比如抛物线的参数方程。其中p是常数为焦点到准线的距离。
<img src="https://static001.geekbang.org/resource/image/73/b2/737ab592aa82bdb7e145aebdc7e104b2.jpeg" alt="">
我们修改上面的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 &lt;= 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 &lt;= 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范围内采样101seg+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 &lt; 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 &lt;= 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 =&gt; 25 * t,
t =&gt; 25 * t ** 2,
);
// 绘制抛物线
para(-5.5, 5.5).draw(ctx);
```
再比如,我们可以绘制出阿基米德螺旋线,代码如下:
```
const helical = parametric(
(t, l) =&gt; l * t * Math.cos(t),
(t, l) =&gt; l * t * Math.sin(t),
);
helical(0, 50, 500, 5).draw(ctx, {strokeStyle: 'blue'});
```
以及,我们还可以绘制星形线,代码如下:
```
const star = parametric(
(t, l) =&gt; l * Math.cos(t) ** 3,
(t, l) =&gt; l * Math.sin(t) ** 3,
);
star(0, Math.PI * 2, 50, 150).draw(ctx, {strokeStyle: 'red'});
```
同时绘制三条曲线后的效果,如下图所示。详细的代码,我都放到了[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/parametric2)。你可以自己动手试一试,看看怎么把它们组合成更多有趣的图形。
<img src="https://static001.geekbang.org/resource/image/3e/b4/3e20fa773a4bfd78a22160832e2fdbb4.jpg" alt="">
### 4. 画贝塞尔曲线
前面我们说的这些曲线都比较常见,它们都是符合某种固定数学规律的曲线。但生活中还有很多不规则的图形,无法用上面这些规律的曲线去描述。那我们该如何去描述这些不规则图形呢?**贝塞尔曲线**Bezier Curves就是最常见的一种解决方式。它在可视化领域中也是一类非常常用的曲线它通过起点、终点和少量控制点就能定义参数方程来生成复杂的平滑曲线所以它通常被用来构建数据信息之间连接线。
<img src="https://static001.geekbang.org/resource/image/54/29/546a855fc34c45cb7e654ffda4f88f29.png" alt="" title="贝塞尔曲线示意图">
贝塞尔曲线又分为**二阶贝塞尔曲线**Quadratic Bezier Curve和**三阶贝塞尔曲线**Qubic Bezier Curve。顾名思义二阶贝塞尔曲线的参数方程是一元二次多项式那么三阶贝塞尔曲线的参数方程是一元三次多项式。接下来我们就分别说说它们的公式和描述曲线的方法
其中二阶贝塞尔曲线由三个点确定P<sub>0</sub>是起点P<sub>1</sub>是控制点P<sub>2</sub>是终点,示意图如下:
<img src="https://static001.geekbang.org/resource/image/5a/36/5a15bb08a9815723a7745119b6328436.jpeg" alt="">
我们可以用parametric构建并绘制二阶贝塞尔曲线代码如下所示
```
const quadricBezier = parametric(
(t, [{x: x0}, {x: x1}, {x: x2}]) =&gt; (1 - t) ** 2 * x0 + 2 * t * (1 - t) * x1 + t ** 2 * x2,
(t, [{y: y0}, {y: y1}, {y: y2}]) =&gt; (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 &lt; 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仓库](https://github.com/akira-cn/graphics/tree/master/bezier)
<img src="https://static001.geekbang.org/resource/image/f7/98/f7228ee8407ea5ee8b2ac2896eef0798.jpeg" alt="" title="二阶贝塞尔曲线效果图">
三阶贝塞尔曲线的参数方程为:
<img src="https://static001.geekbang.org/resource/image/d3/1c/d35dfdfca5abf67f98f35e2ae168771c.jpg" alt="">
可以看到与二阶贝塞尔曲线相比三阶贝塞尔曲线有4个点其中P<sub>0</sub>和P<sub>3</sub>是起点和终点P<sub>1</sub>、P<sub>2</sub>是控制点,所以三阶贝塞尔曲线有两个控制点。
[<img src="https://static001.geekbang.org/resource/image/45/55/45d35cb4e1b446501fcefac07b3dab55.gif" alt="" title="三阶贝塞尔曲线的原理示意图">](http://math001.com/bezier_curve/)
我们同样可以用parametric构建并绘制三阶贝塞尔曲线
```
const cubicBezier = parametric(
(t, [{x: x0}, {x: x1}, {x: x2}, {x: x3}]) =&gt; (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}]) =&gt; (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 &lt; 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的圆上。它和我们之前实现的二阶贝塞尔曲线相比控制得更细致形成的图案信息更丰富。
<img src="https://static001.geekbang.org/resource/image/6a/21/6a86ffe30937734e3601ba3724ab6721.jpeg" alt="" title="三阶贝塞尔曲线效果图">
总的来说贝塞尔曲线对于可视化甚至整个计算机图形学都有着极其重要的意义。因为它能够针对一组确定的点在其中构造平滑的曲线这也让图形的实现有了更多的可能性。而且贝塞尔曲线还可以用来构建CatmullRom曲线。CatmullRom曲线也是一种常用的曲线它可以平滑折线我们在数据统计图表中经常会用到它。
<img src="https://static001.geekbang.org/resource/image/f9/eb/f9dd8a508e3368141d15b85a330378eb.jpg" alt="" title="使用CatmullRom曲线绘制的折线、曲线和部分平滑折线">
实际上Canvas2D和SVG都提供了直接绘制贝塞尔曲线的API比如在Canvas2D中我们可以通过创建Path2D对象使用Path2D支持的SVGPath指令添加贝塞尔曲线。即使如此我们依然需要掌握贝塞尔曲线的基本原理。因为在WebGL这样的图形系统里我们还是需要自己实现贝塞尔曲线的绘制而且贝塞尔曲线除了绘制曲线之外还有其他的用处比如构建平滑的轨迹动画、属性插值等等。这些内容我们也会在后续课程中会深入讨论。
## 要点总结
这一节课我们讨论了用曲线和参数方程描述曲线的方法。
用向量描述比较简单直接,先确定起始点和起始向量,然后通过旋转和向量加法来控制形状,就可以将曲线一段一段地绘制出来。但是它的缺点也很明显,就是数学上不太直观,需要复杂的换算才能精确确定图形的位置和大小。
使用参数方程能够避免向量绘制的缺点,因此是更常用的绘制方式。使用参数方程绘制曲线时,我们既可以使用有规律的曲线参数方程来绘制这些规则曲线,还可以使用二阶、三阶贝塞尔曲线来在起点和终点之间构造平滑曲线。
## 小试牛刀
1. Canvas2D和SVG中都提供了画圆、椭圆、贝塞尔曲线的指令你可以尝试直接使用这些指令来绘制圆、椭圆和贝塞尔曲线然后比较一下使用这些指令和使用我们课程中讲过的方法有什么不同。
1. 除了圆和椭圆这些常见的参数方程你还能自己创造出一些参数方程吗如果可以你可以使用parametric.js把它们绘制出来。
1. 我在课程中画了两个最基础的贝塞尔曲线。你能试着修改parametric.js的代码调整一下贝塞尔曲线控制点参数画出更有趣的图形吗
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[1][绘制圆锥曲线完整代码.](https://github.com/akira-cn/graphics/tree/master/parametric)
[2][绘制其他曲线完整代码.](https://github.com/akira-cn/graphics/tree/master/parametric2)
[3][绘制贝塞尔曲线完整代码.](https://github.com/akira-cn/graphics/tree/master/bezier)
## 推荐阅读
[Parametric.js](https://github.com/akira-cn/graphics/blob/master/common/lib/parametric.js)

View File

@@ -0,0 +1,426 @@
<audio id="audio" title="08 | 如何利用三角剖分和向量操作描述并处理多边形?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/df/b54c7a4228f0d72010608534496bd0df.mp3"></audio>
你好,我是月影。
在图形系统中我们最终看到的丰富多彩的图像都是由多边形构成的。换句话说不论是2D图形还是3D图形经过投影变换后在屏幕上输出的都是多边形。因此理解多边形的基本性质了解用数学语言描述并且处理多边形的方法是我们在可视化中必须要掌握的内容。
那今天,我们就来说说,不同的图形系统是如何用数学语言描述并处理多边形。首先,我们来说说图形学中的多边形是什么。
## 图形学中的多边形是什么?
多边形可以定义为由三条或三条以上的线段首尾连接构成的平面图形,其中,每条线段的端点就是多边形的顶点,线段就是多边形的边。
多边形又可以分为**简单多边形**和**复杂多边形**。我们该怎么区分它们呢?如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。一般来说,我们在绘图时,要尽量构建简单多边形,因为简单多边形的图形性质比较简单,绘制起来比较方便。
而简单多边形又分为凸多边形和凹多边形我们主要是看简单多边形的内角来区分的。如果一个多边形中的每个内角都不超过180°那它就是凸多边形否则就是凹多边形。
<img src="https://static001.geekbang.org/resource/image/74/4a/74c812ef3a15f5f20d7a5bbaff30794a.jpg" alt="">
在图形系统中绘制多边形的时候,最常用的功能是填充多边形,也就是用一种颜色将多边形的内部填满。除此之外,在可视化中用户经常要用鼠标与多边形进行交互,这就要涉及多边形的边界判定。所以今天,我们就来重点讨论**多边形的填充和边界判定**。首先,我们来看多边形的填充。
## 不同的图形系统如何填充多边形?
不同的图形系统会用不同的方法来填充多边形。比如说在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 &lt;= 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 &lt; points.length; i++) {
context.lineTo(...points[i]);
}
if(close) context.closePath();
context.fillStyle = fillStyle;
context.fill(rule);
}
```
我们最终绘制出的效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/37/96/371e3b8d3f484b13aa13f6e8ce60ec96.jpeg" alt="" title="简单多边形a和b">
除了“nonzero”还有一种规则叫做“evenodd”它是根据重叠区域是奇数还是偶数来判断是否填充的。那当我们增加了draw方法的参数将五角星的填充规则改成“evenodd”之后简单多边形没有变化而复杂多边形由于绘制区域存在重叠就出导致图形中心有了空洞的特殊效果。
```
draw(ctx, stars, {rule: 'evenodd'});
```
<img src="https://static001.geekbang.org/resource/image/81/44/81e56244233ebec7e0cc50a661d2cf44.jpeg" alt="" title="使用evenodd之后得到的填充图形a和b">
总之Canvas2D的fill非常实用它可以自动填充多边形内部的区域并且对于任何多边形都能判定和填充你可以自己去尝试一下。
### 2. WebGL如何填充多边形
在WebGL中虽然没有提供自动填充多边形的方法但是我们可以用三角形这种基本图元来快速地填充多边形。因此在WebGL中填充多边形的第一步就是将多边形分割成多个三角形。
这种将多边形分割成若干个三角形的操作,在图形学中叫做**三角剖分**Triangulation
[<img src="https://static001.geekbang.org/resource/image/61/dd/619872b8789bfaeb5fc2c1f0381d52dd.jpeg" alt="" title="同一个多边形的两种三角剖分方法">](http://wikipedia.org)
三角剖分是图形学和代数拓扑学中一个非常重要的基本操作,也有很多不同的实现算法。对简单多边形尤其是凸多边形的三角剖分比较简单,而复杂多边形由于有边的相交和面积重叠区域,所以相对困难许多。
那因为这些算法讲解起来比较复杂,还会涉及很多图形学的底层数学知识,你可能很难理解,所以我就不详细说三角剖分的具体算法了。如果你有兴趣学习,可以自己花一点时间去看一些[参考资料](http://www.ae.metu.edu.tr/tuncer/ae546/prj/delaunay/)。
这里我们就直接利用GitHub上的一些成熟的库常用的如[Earcut](https://github.com/mapbox/earcut)、[Tess2.js](https://github.com/memononen/tess2.js)以及[cdt2d](https://github.com/mikolalysenko/cdt2d)来对多边形进行三角剖分就可以了。具体怎么做呢接下来我们就以最简单的Earcut库为例来说一说WebGL填充多边形的过程。
<img src="https://static001.geekbang.org/resource/image/28/9c/28716416aa8c00743843ae208089c99c.jpeg" alt="" title="简单多边形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);
```
<img src="https://static001.geekbang.org/resource/image/61/26/614e29911f76757a56159ca2d080a526.jpeg" alt="">
你会发现通过上面的步骤整个多边形都被WebGL渲染并填充为了红色。这么一看好像三角剖分并没有什么作用。但实际上WebGL是对这个多边形三角剖分后的每个三角形分别进行填充的。为了让你看得更清楚我们用描边代替填充具体操作就是修改一下gl.drawElements的渲染模式将gl.TRIANGLES改成gl.LINE_STRIP。这样我们就可以清晰地看出经过Earcut处理的这个多边形被分割成了8个三角形。
<img src="https://static001.geekbang.org/resource/image/54/72/549f33d96886fa2bf04f92d30f9ea972.jpeg" alt="">
到这里我们就讲完了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) =&gt; {
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');
}
});
```
最后,上面代码运行效果如下图:
<img src="https://static001.geekbang.org/resource/image/4f/97/4f370d3187e964efc733294a3ed2de97.gif" alt="">
这个运行结果是没有问题的但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) =&gt; {
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方法判断点的位置这样得到的结果如下图
<img src="https://static001.geekbang.org/resource/image/5e/3e/5e566597db9519bcb0fdc09ce6390e3e.gif" alt="">
你会看到当我们将鼠标移动到中间大图时它的颜色并没有发生变化只有移动到右上角的小三角形时这两个图形才会同时变色。这就是因为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连接三角形三个顶点的向量分别为u<sub>1</sub>、u<sub>2</sub>、u<sub>3</sub>那么u点在三角形内部的充分必要条件是u<sub>1</sub> X a、u<sub>2</sub> X b、u<sub>3</sub> X c 的符号相同。
你也可以看我下面给出的示意图当点u在三角形a、b、c内时因为u<sub>1</sub>到a、u<sub>2</sub>到b、u<sub>3</sub>到c的小角旋转方向是相同的这里都为顺时针所以u<sub>1</sub> X a、u<sub>2</sub> X b、u<sub>3</sub> X c要么同正要么同负。当点v在三角形外时v<sub>1</sub>到a方向是顺时针v<sub>2</sub>到b方向是逆时针v<sub>3</sub>到c方向又是顺时针所以它们叉乘的结果符号并不相同。
<img src="https://static001.geekbang.org/resource/image/34/c3/3402b08454dbc39f9543cb4c597419c3.jpg" alt="">
根据这个原理,我们就可以写一个简单的判定函数了,代码如下:
```
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 &amp;&amp; s2 === s3;
}
```
你以为到这里就结束了吗还没有。上面的代码还有个Bug它虽然可以判定点在三角形内部但却不能判定点恰好在三角形某条边上的情况。这又该如何优化呢
在学习了向量乘法之后我们知道。如果一个点u在三角形的一条边a上那就会需要满足以下2个条件
1. a.cross(u1) === 0
1. 0 &lt;= a.dot(u1) / a.length ** 2 &lt;= 1
第一个条件很容易理解我就不细说了我们重点来看第二个条件。下面我就分别讨论一下点u和a在一条直线上和不在一条直线上这两种情况。
<img src="https://static001.geekbang.org/resource/image/ca/8e/ca37834a201b3d704fe40ef3955b608e.jpg" alt="" title="左图是点u和a不在一条直线上右图是点u和a在一条直线上
">
当向量u<sub>1</sub>与a不在一条直线上时u<sub>1</sub>与a的叉乘结果不为0而u<sub>1</sub>与a的点乘的值除以a的长度相当于u<sub>1</sub>在a上的投影。
当向量u<sub>1</sub>与a在一条直线上时u<sub>1</sub>与a的叉乘结果为0u<sub>1</sub>与a的点乘结果除以a的长度的平方正好是u<sub>1</sub>与a的比值。
u<sub>1</sub>与a的比值也有三种情况当u<sub>1</sub>在a上时u<sub>1</sub>和a比值是介于0到1之间的当u<sub>1</sub>在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 &amp;&amp; p &gt;= 0 &amp;&amp; p &lt;= 1) return true;
const s2 = Math.sign(b.cross(u2));
p = b.dot(u2) / b.length ** 2;
if(s2 === 0 &amp;&amp; p &gt;= 0 &amp;&amp; p &lt;= 1) return true;
const s3 = Math.sign(c.cross(u3));
p = c.dot(u3) / c.length ** 2;
if(s3 === 0 &amp;&amp; p &gt;= 0 &amp;&amp; p &lt;= 1) return true;
return s1 === s2 &amp;&amp; s2 === s3;
}
```
这样我们就判断了一个点是否在某个三角形内部。那如果要判断一个点是否在任意多边形的内部,我们只需要在判断之前将它进行三角剖分就可以了。代码如下:
```
function isPointInPath({vertices, cells}, point) {
let ret = false;
for(let i = 0; i &lt; 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就可以完成这并不难。而当我们要实现图形和用户的交互时也就是要判断一个点是否在多边形内部时也需要先对多边形进行三角剖分然后判断该点是否在其中一个三角形内部。
## 小试牛刀
<li>
在课程中我们使用了Earcut对多边形进行三角剖分。但是tess2.js是一个比Earcut更强大的三角剖分库使用tess2.js可以像原生的Canvas2D的fill方法那样实现evenodd的填充规则。你能试着把代码中的earcut换成tess2.js从而实现evenodd填充规则吗动手之前你可以先去读一下tess2.js的项目文档。
</li>
<li>
今天我们用三角剖分实现了不规则多边形。那你能试着利用三角剖分的原理通过WebGL画出椭圆图案、菱形的星星图案以及正五角星吗
</li>
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[使用三角剖分填充多边形、判断点在多边形内部的完整代码](https://github.com/akira-cn/graphics/tree/master/triangluations)
## 推荐阅读
[tess2.js官方文档](https://github.com/memononen/tess2.js)

View File

@@ -0,0 +1,425 @@
<audio id="audio" title="09 | 如何用仿射变换对几何图形进行坐标变换?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/55/e40ab19a83e1783031be21ee91e9cc55.mp3"></audio>
你好,我是月影。
前面两节课,我们学习了用向量表示的顶点,来描述曲线和多边形的方法。但是在实际绘制的时候,我们经常需要在画布上绘制许多轮廓相同的图形,难道这也需要我们重复地去计算每个图形的顶点吗?当然不需要。我们只需要创建一个基本的几何轮廓,然后通过**仿射变换**来改变几何图形的位置、形状、大小和角度。
仿射变换是拓扑学和图形学中一个非常重要的基础概念。利用它,我们才能在可视化应用中快速绘制出形态、位置、大小各异的众多几何图形。所以,这一节课,我们就来说一说仿射变换的数学基础和基本操作,它几乎会被应用到我们后面讲到的所有视觉呈现的案例中,所以你一定要掌握。
## 什么是仿射变换?
仿射变换简单来说就是“线性变换+平移”。实际上在平常的Web开发中我们也经常会用到仿射变换比如对元素设置CSS的transform属性就是对元素应用仿射变换。
再说回到几何图形针对它的仿射变换具有以下2个性质
1. 仿射变换前是直线段的,仿射变换后依然是直线段
1. 对两条直线段a和b应用同样的仿射变换变换前后线段长度比例保持不变
由于仿射变换具有这两个性质,因此对线性空间中的几何图形进行仿射变换,就相当于对它的每个顶点向量进行仿射变换。
那具体怎么操作呢?下面,我们就来详细说说。
## 向量的平移、旋转与缩放
常见的仿射变换形式包括**平移、旋转、缩放**以及它们的组合。其中平移变换是最简单的仿射变换。如果我们想让向量P(x<sub>0</sub>, y<sub>0</sub>)沿着向量Q(x<sub>1</sub>, y<sub>1</sub>)平移只要将P和Q相加就可以了。
<img src="https://static001.geekbang.org/resource/image/3b/b5/3b1afc9f056d4840cb111252bcc179b5.jpeg" alt="" title="平移后的向量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;
}
}
```
但是,我们并没有讨论这个函数是怎么来的,那在这里我们通过三角函数来简单推导一下。
<img src="https://static001.geekbang.org/resource/image/91/18/914yy44e969c9f75f5413295eef29718.jpg" alt="">
假设向量P的长度为r角度是现在我们要将它逆时针旋转⍬角此时新的向量P的参数方程为
<img src="https://static001.geekbang.org/resource/image/73/1b/7383bf5a2529bc6b1687617769b6da1b.jpeg" alt="">
然后因为rcos、rsin是向量P原始的坐标x<sub>0</sub>、y<sub>0</sub>,所以,我们可以把坐标代入到上面的公式中,就会得到如下的公式:
<img src="https://static001.geekbang.org/resource/image/88/f4/88aea77872789dfb0322db466315f5f4.jpeg" alt="">
最后,我们再将它写成矩阵形式,就会得到一个旋转矩阵。至于为什么要写成矩阵形式,我后面会讲,这里你先记住这个旋转矩阵的公式就可以了。
<img src="https://static001.geekbang.org/resource/image/e5/a2/e52cae6173e2b4056e9aa752a93076a2.jpeg" alt="">
**然后是缩放变换**。缩放变换也很简单,我们可以直接让向量与标量(标量只有大小、没有方向)相乘。
<img src="https://static001.geekbang.org/resource/image/46/72/46d1bb8b507b1f1c9bc14dd6715a4372.jpeg" alt="">
对于得到的这个公式,我们也可以把它写成矩阵形式。结果如下:
<img src="https://static001.geekbang.org/resource/image/2b/a4/2b15e082213c56756686771526afbda4.jpg" alt="">
现在,我们就得到了三个基本的仿射变换公式,其中旋转和缩放都可以写成矩阵与向量相乘的形式。这种能写成矩阵与向量相乘形式的变换,就叫做**线性变换**。线性变换除了可以满足仿射变换的2个性质之外还有2个额外的性质
1. 线性变换不改变坐标原点因为如果x<sub>0</sub>、y<sub>0</sub>等于零那么x、y肯定等于0
1. 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与原始向量相乘。
那根据线性变换的第2条性质我们就能总结出一个通用的线性变换公式即一个原始向量P<sub>0</sub>经过M<sub>1</sub>、M<sub>2</sub>、…M<sub>n </sub>次的线性变换之后得到最终的坐标P。线性变化的叠加是一个非常重要的性质它是我们对图形进行变换的基础所以你一定要牢记线性变化的叠加性质。
<img src="https://static001.geekbang.org/resource/image/de/c7/deca8b0bce015f249a48a5c6e7dcdfc7.jpeg" alt="">
好了,常见的仿射变换形式我们说完了。总的来说,向量的基本仿射变换分为平移、旋转与缩放,其中旋转与缩放属于线性变换,而平移不属于线性变换。基于此,我们可以得到仿射变换的一般表达式,如下图所示:
<img src="https://static001.geekbang.org/resource/image/c2/57/c275c765a311e4faa2845435f9d54e57.jpg" alt="">
## 仿射变换的公式优化
上面这个公式我们还可以改写成矩阵的形式,在改写的公式里,我们实际上是给线性空间增加了一个维度。换句话说,我们用高维度的线性变换表示了低维度的仿射变换!
<img src="https://static001.geekbang.org/resource/image/53/27/53e134cae1bfced9e5a1bd60df0aed27.jpeg" alt="">
这样我们就将原本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官方文档](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniform)。
接下来我们实现这个函数将随机三角形信息传给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 &lt; 5 * Math.random(); i++) {
triangles.push(randomTriangles());
}
gl.clear(gl.COLOR_BUFFER_BIT);
// 对每个三角形重新设置u_time
triangles.forEach((triangle) =&gt; {
triangle.u_time = (performance.now() - triangle.startTime) / 1000;
setUniforms(gl, triangle);
gl.drawArrays(gl.TRIANGLES, 0, position.length / 2);
});
// 移除已经结束动画的三角形
triangles = triangles.filter((triangle) =&gt; {
return triangle.u_time &lt;= 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表示它的最大移动距离为 2p * 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倍。实际上相当于我们做了如下变换:
<img src="https://static001.geekbang.org/resource/image/5e/98/5e18daef0ff059498804419b704c6a98.jpeg" alt="">
这里我就不再自己写矩阵乘法的库了我们用一个向量矩阵运算的数学库math它几乎包含了所有图形学需要用到的数学方法我们在后面课程中也会经常用到它你可以参考[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/common/lib/math)先了解一下它。
我们简单算一下三个矩阵相乘,代码如下:
```
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) =&gt; {
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个性质
1. 变换前是直线段的,变换后依然是直线段
1. 对两条直线段a和b应用同样的仿射变换变换前后线段长度比例保持不变
那仿射变换中的旋转和缩放又属于线性变换而线性变换在仿射变换性质的基础上还有2个额外的性质
1. 线性变换不改变坐标原点因为如果x<sub>0</sub>、y<sub>0</sub>等于零那么x、y肯定等于0
1. 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与向量相乘
通过齐次坐标和齐次矩阵,我们可以将平移这样的非线性仿射变换用更高维度的线性变换来表示。这么做的目的是让我们能够将仿射变换的组合简化为矩阵乘法运算。
到这里,数学基础篇的内容我们就学完了。在这一篇的开头,我们说了要总结出一个通用的基础数学绘图体系,这样才不至于陷入细节里。所以啊,我总结了一个简单的知识脑图,把我们在数学篇里讲过的数学知识汇总到了一起,它肯定不会是一个非常完整的数学绘图体系,但是对我们之后的学习来说,已经足够用了。
<img src="https://static001.geekbang.org/resource/image/bf/7c/bfdd8c7f5f15e5b703128cdaf419f07c.jpg" alt="">
最后呢,我还想再啰嗦几句。图形学作为可视化的基础,是一门很深的学问。它牵涉的数学内容非常多,包括线性代数、几何、微积分和概率统计等等。那这门课里我们所介绍的数学知识,其实还都只是一些入门知识。
那如果你对图形学本身很感兴趣想要深入学习它在其他领域比如游戏、视频、AR/VR等领域的应用这里我推荐你一些深入学习的资料。
1. [3Blue1Brown的数学和图形学基础课程](https://www.youtube.com/watch?v=fNk_zzaMoSs&amp;list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab) 讲得深入浅出,是非常棒的入门教程。
1. [《Fundamentals of Computer Graphics》](https://book.douban.com/subject/26868819/)这本书是图形学入门的经典教材。
## 小试牛刀
<li>
在实现粒子动画的时候我们让translateMatrix * rotateMatrix * scaleMatrix这三个矩阵按这样的顺序相乘。那如果我们颠倒它们的相乘次序把roateMatrix放到translateMatrix前面或者把scaleMatrix放到translateMatrix前面会产生什么样的结果呢为什么呢你可以思考一下然后从GitHub上fork代码动手试一试。
</li>
<li>
<p>我们知道CSS的transform除了translate、rotate和scale变换以外还有skew变换。skew变换是一种沿着轴向的扭曲变换它也属于一种线性变换它的变换矩阵是<br>
<img src="https://static001.geekbang.org/resource/image/b2/44/b265fbd6719e6785c9d0da9364a91f44.jpeg" alt=""></p>
</li>
你可以使用这个矩阵,给我们的粒子动画加上随机的扭曲效果吗?
1. 因为齐次坐标和齐次矩阵的概念可以从二维一直推广到N维而且CSS的transform还支持3D变换。那你可以用齐次矩阵的原理对CSS属性的3D变换应用matrix3d实现出有趣的3D变换效果吗💡小提示要支持3维的齐次坐标需要4维齐次矩阵
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[1]粒子动画的[完整代码](https://github.com/akira-cn/graphics/tree/master/webgl_particles)
[2]矩阵运算数学库的[完整代码](https://github.com/akira-cn/graphics/tree/master/common/lib/math)
## 推荐阅读
[1]WebGL的uniform变量设置[官方文档](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniform)
[2][3Blue1Brown的数学和图形学基础课程](https://www.youtube.com/watch?v=fNk_zzaMoSs&amp;list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab)
[3]图形学入门经典教材[《Fundamentals of Computer Graphics》](https://book.douban.com/subject/26868819/)

View File

@@ -0,0 +1,504 @@
<audio id="audio" title="31 | 针对海量数据,如何优化性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/16/4de5826f092f7782458cfdd30e646616.mp3"></audio>
你好,我是月影。
前两节课我们一起学习了Canvas2D和WebGL性能优化的一些基本原则和处理方法。在正确运用这些方法后我们能让渲染性能达到较高的程度满足我们项目的需要。
不过,在数据量特别多的时候,我们会遇到些特殊的渲染需求,比如,要在一个地图上标记非常多的地理位置点(数千到数万),或者在地图上同时需要渲染几万条黑客攻击和防御数据。这些需求可能超过了常规优化手段所能达到的层次,需要我们针对数据和渲染的特点进行性能优化。
今天我通过渲染动态地理位置的例子来和你说说如何对特殊渲染需求迭代优化。不过我今天用到特殊优化手段只是一种具体的方法和手段你可以借鉴他去理解思路但千万不要陷入到思维定式中。因为解决这些特殊渲染需求并没有固定的路径或方法它是一个需要迭代优化的过程需要我们对WebGL的渲染机制非常了解并深入思考才能创造出最适合的方法来。在我们实际的工作里还有许多其他的方法可以使用你一定要根据自己的实际情况随机应变。
## 渲染动态的地理位置
我先来看今天要实现的例子。在地图可视化应用中,渲染地理位置信息是一类常见的需求,例如在这张地图上,我们就用许多不同颜色的小圆点标注出了美国一些不同的地区。
<img src="https://static001.geekbang.org/resource/image/ca/4a/caebcea9dd803d01fa4188faf98f9f4a.jpeg" alt="">
如果我们要实现这些静态的标准点方法其实很简单用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 &lt; 32; i++) {
const angle = i * delta;
positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
if(i &gt; 0 &amp;&amp; i &lt; 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 &lt; 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了。那我们该怎么优化呢
<img src="https://static001.geekbang.org/resource/image/83/78/83f68759c4913bea230fc52d08ac9578.jpeg" alt="">
## 优化大数据渲染的常见方法
我们通过前面的学习已经知道渲染次数和每次渲染的顶点计算次数是影响渲染性能的要素,所以优化大数据渲染的思路方向自然就是减少渲染次数和减少几何体顶点数了。
### 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 &lt; 32; i++) {
const angle = i * delta;
positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
if(i &gt; 0 &amp;&amp; i &lt; 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 &lt; 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倍
<img src="https://static001.geekbang.org/resource/image/10/81/10cc6bc21a8d9d89e06760ddf4118381.jpeg" alt="">
之所以批量渲染的性能比逐个渲染要高得多是因为我们通过减少绘制次数大大减少了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%,最终在画布上就呈现出一个蓝色矩形。
<img src="https://static001.geekbang.org/resource/image/47/fb/47b1af96385ce25571f0892b1474c6fb.jpeg" alt="">
注意,这里你可以回顾一下之前我们采用的常规的方法绘制的矩形,我们是将矩形剖分为两个三角形,然后用填充三角形来绘制的。而这里,我们用点图元,好处是我们只需要一个顶点就可以绘制,而不需要用四个顶点、两个三角形来填充。
现在我们通过点图元改变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将一定距离内的图形绘制出来这样就得到一个蓝色的圆。
<img src="https://static001.geekbang.org/resource/image/62/35/6220f0e98ae2cfc95189df136d59bb35.jpeg" alt="">
用这样的思路呢,我们就可以得到新的绘制大量圆的方法了。这种思路实现圆的代码如下:
```
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 &lt; 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以上性能又提升了超过一倍
<img src="https://static001.geekbang.org/resource/image/9f/a7/9fa5964587dbb8edf192d3de95c938a7.jpeg" alt="">
## 其他方法
这里举上面的这个例子主要是想说明一个问题即使是使用WebGL不同的渲染方式性能的差别也会很大甚至会达到数千倍的差别。因此在可视化业务中我们一定要学会**根据不同的应用场景来有针对性地进行优化**。说起来简单要做到这一点并不容易你需要对WebGL本身非常熟悉而且对于GPU的使用、渲染管线等基本原理有着比较深刻的理解。这不是一朝一夕可以做到的需要持续不断地学习和积累。
就像有些同学使用绘图库ThreeJS或者SpriteJS来绘图的时候做出来的应用性能很差就会怀疑是图形库本身的问题。实际上这些问题很可能不是库本身的问题而是我们使用方法上的问题。换句话说是我们使用的绘图方式并不是最适用于当前的业务场景。而ThreeJS、SpriteJS这些通用的绘图库也并不会自己针对特定场景来优化。
因此单纯使用图形库我们绘制出来的图形就没法真正达到性能极致。也正是因为这个原因我没有把这门课程的重点放在库的API的使用上而是深入到图形渲染的底层原理。只有掌握了这些你才能真正学会如何驾驭图形库做出高性能的可视化解决方案来。
针对场景的性能优化方法其实非常多,我刚才讲的也只是几种典型的情况。为了帮助你在实战中慢慢领悟,我再举几个例子。不过我要提前说一下,我不会具体去讲这些例子的代码,只会重点强调常用的思路。学会这些方法之后,你再在实践中慢慢应用和体会就会容易很多了。
**1. 使用后期处理通道优化**
我们已经学习过使用后期处理通道的基本方法。实际上后期处理通道十分强大它最重要的特性就是可以把各种数据存储在纹理图片中。这样在迭代处理的时候我们就可以用GPU将这些数据并行地读取和处理从而达到非常高效地渲染。
这里是一个OGL官网上[例子](https://oframe.github.io/ogl/examples/?src=post-fluid-distortion.html)它就是用后期处理通道实现了粒子流的效果。这样的效果在其他图形系统中或者WebGL不使用后期处理通道是不可能做到的。
<img src="https://static001.geekbang.org/resource/image/88/2d/886d5b36c0ab94294yy378ab67f0ea2d.gif" alt="">
这里的具体实现比较复杂但其中最关键的一点是我们要将每个像素点的速度值保存到纹理图片中然后利用GPU并行计算的能力对每个像素点同时进行处理。
**2. 使用GPGPU优化**
还有一种优化思路和后期处理通道很像刚好OGL官网也有这个例子它使用了一种叫做GPGPU的方式也叫做通用GPU方式就是把每个粒子的速度保存到纹理图片里实现同时渲染几万个粒子并产生运动的效果。
<img src="https://static001.geekbang.org/resource/image/07/d6/07ed4788452a2e214c61fb634fcdd8d6.gif" alt="">
**3. 使用服务端渲染优化**
最后一种优化思路,是从我之前做过的一个可视化项目中提取出来的。当时,我需要渲染数十万条历史数据的记录,如果单纯在前端渲染,性能会成为瓶颈。但由于这些数据都是历史数据,因此针对这个场景我们可以在服务端进行渲染,然后直接将渲染后的图片输出给前端。
要使用服务端渲染,我们可以使用 [Node-canvas-webgl](https://github.com/akira-cn/node-canvas-webgl) 这个库它可以在Node.js中启动一个Canvas2D和WebGL环境这样我们就可以在服务端进行渲染然后再将结果缓存起来直接提供给客户端。
## 要点总结
这节课我们主要讲了针对业务的不同应用场景进行性能优化的思路和方法。尤其是在海量数据的情况下,特定优化手段显得十分重要,甚至有可能产生上千倍的性能差距。
结合今天的例子对于要绘制大量圆形的场景我们用常规的处理方法渲染500个元素都比较吃力一旦我们使用了批量渲染就可以把性能一下子提升两千倍以上能够轻松渲染10万个元素如果我们再使用点图元结合造型函数的方法就能轻松渲染20万个以上的元素了。像这样的优化方法需要我们理解业务场景并对WebGL和GPU、渲染管线等有深入的理解再在项目实践中慢慢积累。
除此以外我们还简单介绍了其他的一些优化手段包括使用后期处理通道、使用GPGPU和使用服务端渲染。你可以结合我给出的例子去深入理解。
## 小试牛刀
今天,我们使用点图元结合造型函数的思路,绘制了正方形和圆,你还能绘制出其他不同图形吗?比如圆、正方形、菱形、花瓣、苹果或葫芦等等。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码](https://github.com/akira-cn/graphics/tree/master/performance-more)
## 推荐阅读
[1] [Post Fluid Distortion](https://oframe.github.io/ogl/examples/post-fluid-distortion.html)
[2] [GPGPU Particles (General-Purpose computing on Graphics Processing Units)](https://oframe.github.io/ogl/examples/gpgpu-particles.html)
[3] [Node-canvas-webgl](https://github.com/akira-cn/node-canvas-webgl)

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="32 | 数据之美:如何选择合适的方法对数据进行可视化处理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/yy/95805ab2a62c874d0dd35cfa7f2c53yy.mp3"></audio>
你好,我是月影。
我们知道,可视化包括视觉和数据两大部分。通过前面的课程,我们完成了可视化中视觉呈现部分的学习,学会了用某种技术把数据展现给用户,产生丰富的、生动的、炫酷的视觉效果。今天,我们正式进入数据篇,开始学习数据处理。
你可能会问,学习可视化设计一定要学会处理数据吗?答案是一定要学。因为在可视化项目中,我们关注的信息经常会隐藏在大量原始数据中,而原始数据又包含了太过丰富的信息。其中大部分信息不仅对我们来说根本没用,还会让我们陷入信息漩涡,忽略掉真正重要的信息。
因此,只有深入去理解数据,学会提炼、处理以及合理地使用数据,我们才能成为一名优秀的可视化工程师。
那数据究竟是怎么从原始数据中获取的,又是怎么被我们用可视化的方式表达出来的呢?其实方法有很多,不过这节课我先举三种方法,让你对可视化数据处理手段有一个全面的认知,后几节课我们再深入讲解一些比较通用的数据处理技巧。
## 从原始数据中过滤出有用的信息
首先,我们明确一点,在可视化中,我们处理数据的目的就是,从数据中梳理信息,让这些信息反应出数据的特征或者规律。一个最常用的技巧就是按照某些属性对数据进行过滤,再将符合条件的结果展现出来,最终让数据呈现出我们希望用户看到的信息。
这么说可能还不太好理解我们来看一个简单的例子。假设现在有一个小公园公园有四个区域分别是广场、休闲区、游乐场以及花园。每天上午8点、中午12点、下午6点以及晚上8点这四个时间公园管理处会通过航拍收集4个区域上人群的分布信息得到每天人群分布的数据之后公园管理者就能够利用这些数据来优化公园的娱乐设施了。
具体该怎么做呢?利用可视化来解决这个问题会非常简单,思路就是先把人群的分布数据绘制成合适的可视化图表,从中分析出人群分布的规律。
这里,我仿造了一组人群数据,将它放在[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/data/park-people/data.json)里,数据格式如下:
```
[{
&quot;x&quot;: 456,
&quot;y&quot;: 581,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;f&quot;
}, {
&quot;x&quot;: 293,
&quot;y&quot;: 545,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
&quot;x&quot;: 26,
&quot;y&quot;: 470,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
&quot;x&quot;: 254,
&quot;y&quot;: 587,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
&quot;x&quot;: 385,
&quot;y&quot;: 257,
&quot;time&quot;: 8,
&quot;gender&quot;: &quot;m&quot;
},
...]
```
数据是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 &lt; 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) =&gt; {
return res.json();
}).then((data) =&gt; {
draw(data);
});
```
最终绘制出来的效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/f7/1c/f7265e0e16788f20f4916617e8c9471c.jpeg" alt="">
我们可以看到这样绘制出来的分布图上显示了每天访问公园的人群他们很均匀地分散在公园各处似乎并没有什么特殊的地方。这其实是因为我们并没有根据其他属性来过滤这些数据。我们可以先试着根据时间来过滤。我们修改一下代码给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 &lt; 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) =&gt; {
return res.json();
}).then((data) =&gt; {
draw(data, ({time}) =&gt; time === 8);
// draw(data, ({time}) =&gt; time === 12);
// draw(data, ({time}) =&gt; time === 18);
// draw(data, ({time}) =&gt; time === 20);
});
```
把数据按照8点、12点、18点、20点分别过滤之后我们就能得到不同时间的游客散点图如下图所示。
<img src="https://static001.geekbang.org/resource/image/4e/bf/4eb0cec6dc008ec550c5f23e7b23fcbf.jpeg" alt="">
我们先看左上角也就是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 &lt; 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) =&gt; {
return res.json();
}).then((data) =&gt; {
draw(data, ({time}) =&gt; time === 12);
});
```
标记完我们再来看一下12点的游客散点图。
<img src="https://static001.geekbang.org/resource/image/2d/25/2d2cfaa2240d8ae77514eeaee888a825.jpeg" alt="">
我们看到,集中再游乐场和休闲区的主要是男游客,而女游客更喜欢呆在花园。这可能是和游乐场的游乐设施以及休闲区的设计有关。
到这里,我们关于公园游客的可视化分析就告一段落了。通过我们分析得出的规律,对游乐场改进游乐设施和日常管理是有实际的参考作用。
因为这里的数据是我仿造的数据,所以不一定符合真实情况,不过这并不重要。通过这个例子,我主要是想让你体会数据可视化分析的一般过程,通常我们通过数据过滤和展示,从中提取出有用信息,以便于做出后续的决策,这就是数据可视化的价值所在。只要数据是客观的,分析过程是合理的,那数据表现出来的结果就是具有实际意义的。
## 强化展现形式让用户更好地感知
在前面的例子里,我们用散点图呈现游客信息并从中分析出有用的内容,这种形式直观有效,但是展现形式略显单调。除了合理的数据分析以外,数据可视化有时候通过强化展现形式,让用户更好地感知数据表达的内容。这样能够帮助需要关注该数据的用户,更好地把握整体信息。
我在GitHub看到一个非常合适的例子我们来看一下。
>
空气质量和我们生活质量息息相关,那在过去的几年里,雾霾和蓝天交替,成为我们生活的一部分。近几年来,国家一直在大力治理空气污染。那在这种情况下,我们的空气质量到底有没有变好呢?
一名[亚赛](https://wangyasai.github.io/)同学写了一个[北京空气质量2015-2018的可视化展现](https://wangyasai.github.io/BeijingAirNow/)利用每天北京空气的AQI值绘制了色条他还用心地让每一天对应了一个地标的当天实拍照片。这不仅增加了项目整体的趣味性也强化了用户的直观认知。
[<img src="https://static001.geekbang.org/resource/image/36/d6/3627ff3a70270ce759ec38ebf7d7a5d6.gif" alt="" title="北京空气质量2015-2018可视化展现图片来源wangyasai.github.io)">](https://wangyasai.github.io/Work/beijingsky.html)
如上面示意图所示我们将每一年的数据按照AQI排序后可以明显看出灰色的区域在逐年减少这说明北京的空气质量的确是在逐年好转的。
这个项目的代码在亚赛同学的[GitHub仓库](https://github.com/wangyasai/wangyasai.github.io/tree/master/Work)里我就不拿出来细讲了。因为这个可视化效果的实现原理并不复杂而且这节课我们更应该学习和理解用数据进行可视化展现的思路而不是代码实现细节。当然如果你想搞懂代码那你可以深入分析一下GitHub仓库里的源码。这个项目代码具体实现是依赖一个叫做p5.js的图形库它也是一个很棒也很有趣的图形库用来学习可视化也非常合适如果你有兴趣可以去看一下。
如果你想要亲自动手实现一个这样的可视化项目,我也建议你借鉴亚赛同学这个项目中的数据和思路,对它进行一些改进。
## 将信息的特征具象化
到现在为止,你可能认为可视化都是需要使用真实数据来呈现的,数据越真实、越详细,可视化效果呢就越好。如果有了这个想法,说明你有一点陷入到思维定式中了。实际上,有时候我们并不要求数据越真实越详细,甚至不要求绝对真实的数据,只需要把数据的特征抽象和提取出来,再把代表数据最鲜明的特征,用图形化、令人印象深刻的形式呈现出来,这就已经是成功的可视化了。
比如下面这张图,就用可视化的方式解释了数据、信息、知识、见解、智慧和影响力。这种可视化呈现的数据并不是真实、准确地,而是带有趣味性的,通过对信息特征进行抽取,让看的人形成了一种视觉认知。
<img src="https://static001.geekbang.org/resource/image/60/9f/607a51a7115e678c93f614e9d781c69f.jpeg" alt="" title="数据-信息-知识-洞见-智慧-影响力(图片来源gapingvoid)">
其实这样的可视化例子还有很多比如Matt Might教授绘制的“[图解博士是什么](http://matt.might.net/articles/phd-school-in-pictures/)”也非常有趣。由于图片很长,我在这里就不列出来了。虽然它的数据不是基于海量数据提取的,但却是一组概念的具象化,所以它毫无疑问也是一个非常成功的可视化方案例。
除此之外Manu Cornet的组织架构图也用非常形象的方法绘制出了各个知名公司的组织架构差异。它的数据当然也不是各个公司详细的组织架构数据而是根据每个公司组织架构特征直接图形化形成的。
[<img src="https://static001.geekbang.org/resource/image/48/1a/48636b60eec64da7fyye1064907d0c1a.jpeg" alt="" title="(图片来源bonkersworld)">](https://bonkersworld.net/organizational-charts)
看了前面这些例子,你可能会有疑问,第三种方法似乎和原始数据并没有关联,而是直接用信息特征来完成的可视化,那第一、二种方法和数据处理过程和它又有什么关系呢?
实际上,我们使用第三种方法,也就是信息特征具象化的前提,是我们真正掌握了我们需要的信息特征,而这些特征的提取和掌握,正是通过前面两种方法迭代出来的!用一句话总结就是,数据可视化本身是一个不断迭代的过程。
具体过程是,我们先进行原始数据的信息收集和分类处理,再通过原始方法表达出有用的信息,接着通过强化展现形式,让信息的核心特征变得更加鲜明,经过这一轮或者几轮的迭代,我们就可能拿到最本质的信息了,最终我们再把这些信息具象化,就可以达到令人印象深刻的效果了。
所以,对原始数据进行不断迭代,就是数据可视化的基本方法论。我希望你能牢牢记住这句话,并且在实践中认真去做。
## 要点总结
这节课,我们主要讲了对数据进行可视化处理的三种常见方法。
第一种,是从原始数据中过滤出有用的信息。这是数据可视化处理的第一步,也是最基础的方法。第二种,是强化数据的展现形式,让用户更好地感知我们要表达的信息。这是我们在第一步的基础上对数据进行的加工处理。而第三种,是把数据的特征具象化,然后用图形表达。这是我们在第一、二步的基础上,对数据进一步的抽象和提取。如果达到这一步,我们甚至有可能完全脱离原始数据,不依赖原始数据,而是着眼于数据特征的表现形式。
这三种方法层层递进,是数据可视化的基本方法论,而数据可视化本身,其实就是使用这些方法对数据信息进行不断迭代和构建的过程。
## 小试牛刀
你能借助我们前面说的北京空气质量,这个可视化例子中的代码,实现一个你想要的可视化展现吗?你可以不完全按照亚赛同学的方法,多加些自己的创意,以及我们前面学过的图形学技巧,相信你能做出非常好的效果。当然了,我也知道这个挑战有点难,但整个实现的过程能让你把学到的图形学知识融会贯通,所以我还是建议你尝试一下。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整实例代码](https://github.com/akira-cn/graphics/tree/master/data/park-people)
## 推荐阅读
[1] [北京空气质量2015-2018的可视化展现](https://wangyasai.github.io/BeijingAirNow/)
[2] [图解博士是什么](http://matt.might.net/articles/phd-school-in-pictures/)

View File

@@ -0,0 +1,194 @@
<audio id="audio" title="33 | 数据处理(一):可视化数据处理的一般方法是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/6d/45bf979362d5001a77878f5220d3be6d.mp3"></audio>
你好,我是月影。
在数据处理的过程中,我们经常遇到两种情况:一种是数据太少,我们没法找到有用的信息,也就无法进行可视化呈现。另一种是数据太多,信息纷繁复杂,我们经常会迷失在信息海洋中,无法选择合适的可视化呈现方式,最终也表达不了多少有意义的内容。
那你可能想问了,想要解决这两种情况,我们能用上节课讲过的三种数据处理方法吗?事实上,上节课的方法是数据可视化的基本方法论,你可以在可视化过程中借鉴它们的思路,但是它们并不系统。
因此,我们在探索数据可视化的时候,还需要一个合理的数据可视化分析过程作为参照。从这一节课开始,我们就来系统地讨论数据处理的一般方法。
## 数据可视化的一般过程
针对课程一开始这两种情况,就算是不学数据处理的一般思路,我们也知道,如果你的数据太少,你要想办法获取更多的数据,而如果你的数据太多,那你就需要学会运用正确的方法不断迭代、筛选。而且,数据过多的情况我们遇到得更多。
当你学会在众多复杂的数据中准确地抽取信息,把这些数据的某一面可视化出来的时候,你就已经能够轻松地从数据中得到你想要的内容。通过这个过程,有可能让你从数据的一面获得启发,从而发现数据其他方面的有趣内容,进而产生出更多不同的图表,让数据呈现出更多的意义。
所以在数据可视化中你有什么样的数据其实是最重要的而我们的可视化手段会随着具体数据集的不同而不同。因此我们一般会围绕4个问题对可视化过程进行迭代它们分别是你有什么数据你想从数据中了解什么信息你想用什么样的可视化方式呈现以及你看到了什么它有意义吗并且前一个问题的答案都会影响后一个问题的答案。
结合这几个问题,我把数据可视化的一般过程用一个流程图画了出来。接下来,我们就分别说说每一步具体是怎么操作的。
<img src="https://static001.geekbang.org/resource/image/6b/c2/6b2c01e696e7432461de6e82753fccc2.jpeg" alt="" title="可视化迭代过程">
### 你有什么数据?
我们在实际处理可视化数据的时候,经常会遇到可视化项目最终产出的结果与设计预期和客户的期望相差甚远。
比如说产品经理经常会提出一些需求可能是要我们实现某种复杂的可视化大屏或者指定一些竞品中的图表形式让我们模仿有时候甚至会在数据给到我们之前先完成UI设计。可是等到我们最终拿到数据之后却发现数据的信息不足以支撑这些图表的展现形式或者数据的内容在这些图表上表现得不好。
其实这不是产品经理能力不足、也不是可视化工程师能力不足,而是我们一开始就搞错了步骤,我们应该先分析真实数据,找到数据的特点,比如我们可以按照时间、地点、性别对数据进行分类。然后,我们再研究具体的数据呈现形式。因此,做可视化项目的第一步,就是要先了解自己掌握的数据,而不同的数据要了解的内容不同,我们要根据实际情况来具体分析。
### 你想从数据中了解到什么信息?
在得到并且了解原始数据之后,接下来我们该怎样着手获取想要的信息呢?我们[以第27节课](https://time.geekbang.org/column/article/277226)用到的GitHub贡献数据为例。当时我们想实现GitHub用户代码贡献图表GitHub贡献数据已经被平台加工过平台已经把每个用户的贡献信息整理出来了所以这类数据非常简单我们直接用就可以了。
不过我们可以想象一下GitHub贡献数据的原始数据长什么样。我认为它应该包含了每个GitHub代码仓库提交的日志并且每一条日志对应一个提交人和一个提交时间。要把这些数据整理成GitHub用户代码贡献数据GitHub平台需要对这些原始数据按日期和提交人进行归总统计出每个用户在具体某个日期的代码提交次数。
假设,现在我们不想要统计每个用户每天的代码贡献数据,而是要统计每个代码仓库每天的代码贡献数据。那么,我们只需要对原始数据换一种方式处理,将日志不按照提交人进行归总,而是按照提交的仓库进行归总。这样我们就可以得到数据的另一面,从而得到一个完全不同的可视化内容,也就是代码仓库每日的代码贡献次数。
所以,当我们确定了想要表达的信息之后,如果数据中有,我们就直接拿来用,如果没有,我是需要自己来处理的。
### 使用何种可视化方式呈现?
当确定了想要了解的信息之后,会有很多视觉呈现方式供我们选择。那我们该如何选择呢?核心原则就是一条:当为数据选择正确的呈现形式时,我们要学会从不同的角度思考数据,把握住最希望被用户关注到的那些内容,然后用直观的、有趣的以及更加印象深刻的方式来进行表达。
你可能会说,道理我都懂,可具体该怎么做呀?
其实具体怎么做并没有定论我们只能通过例子慢慢积累经验随着经验的丰富你就能慢慢找到设计合适的可视化方式的感觉。比如说现在你就可以想想我在下面给出的这个例子。例子中给出的数据图表是Tim De Chant的世界人口地图。其中一共有6个城市每个城市的人口密度不同如果我们要把全球70亿人都放在任意密度的城市中这个城市会有多大呢
<img src="https://static001.geekbang.org/resource/image/10/3f/10f90401ba636e6027c1355c503b2d3f.jpeg" alt="">
我的建议你可以好好想到底怎么做,有机会的话一定要去实践。在实践的过程中,当你尝试不同的标尺、颜色、形状、大小和几何图形的时候,就会看到可以值得探索的视觉呈现形式了。另外,给数据增加直观性和趣味性,也能够让朴实的数据立刻生动起来。这也是实践你学过的视觉呈现技术的最好时机。
### 你看见了什么?它有意义吗?
可视化数据之后,我们需要从中挖掘出一些有价值的信息,包括但不限于数据信息的规律(增加、减少、离群值等),以及数据信息的异常(明显差异、规律改变)等等,然后将这些规律和异常归因,帮助我们真正了解数据背后有价值的内容。这些正是可视化的意义所在。
客观的数据是有说服力的,因为客观数据一旦产生就不会改变,所以,我们一般认为这样的数据就是事实。但有用的信息往往隐藏得比较深,因此,数据可视化最终目的就是将这些信息挖掘出来,呈现在人们眼前。
到这里,数据可视化的一般过程我们就讲完了。总之,在对数据进行四个步骤的迭代过程中,不同的数据以及不同的数据处理方式,在迭代中会产生不同的呈现形式。为了让你更深刻地理解这一点,我就以上节课游乐场的数据为例,带你体验一次数据处理的全过程。
## 实战演练:对公园中的游客进行数据可视化
首先我们来看我们有什么数据。我们的原始数据的格式就像下面记录的一样,有时间、地点和性别。
```
[{
&quot;x&quot;: 456,
&quot;y&quot;: 581,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;f&quot;
}, {
&quot;x&quot;: 293,
&quot;y&quot;: 545,
&quot;time&quot;: 12,
&quot;gender&quot;: &quot;m&quot;
}, {
...
}]
```
接下来我们看一下我们想了解什么,假设我们想了解公园一天中的**游客变化规律**,那么我们可以用分类的思路处理数据。在上节课,我们就对数据进行了简单的**时间分类**、**地点分类**和**性别分类**。这里我们用d3的数据变换Transformations模块将原始数据处理成我们想要的模式。
这里,我详细说说我们用到的[d3.rollups](https://github.com/d3/d3-array/blob/v2.7.1/README.md#rollups) 它可以对数据进行分组然后汇总。这个接口设计得比较函数式functional)它接受3个参数第一个参数是要处理的数据也就是上面的原始数据后面两个参数是两个函数算子第一个算子表示对数据分组进行汇总的方式这里是使用length来汇总也就是统计数据的条目数。第二个算子则表示对数据进行分组的属性这里是用时间属性进行分组。最后我们在分组之后再对数据进行一次排序因为我们要按照时间从小到大进行排序。
```
(async function() {
const data = await (await fetch('data.json')).json();
const dataset = d3.rollups(data, v =&gt; v.length, d =&gt; d.time).sort(([a],[b]) =&gt; 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) =&gt; {
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 =&gt; 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);
```
在创建坐标轴的时候我们需要给坐标轴设置一个scaled3中间应用了很多函数式编程思想这里的scale也是一个函数算子它是由高阶函数d3.scaleLinear创建的我们给它设置domain从0到24表示一天的24个小时range从0到480表示占据480像素宽度。
然后我们再通过d3.axisBottom高阶函数用创建的scale来生成一个具体的坐标轴算子axis然后对SVG对象应用这个算子就可以绘制出坐标轴的图像了。最终的效果如下图所示
<img src="https://static001.geekbang.org/resource/image/92/yy/92fc979762a3499822231054a69248yy.jpeg" alt="" title="公园24小时游客人数变化图">
最后我们能从这张图上看出些什么信息呢在这张图上我们看到公园的游客数有两个高峰期分别是早8点和傍晚6点而12点的游客是比较少晚上8点之后到10点闭园之前游客的数量是最少的。
这样,我们就得到了一天中游园人数的变化趋势,这对公园的管理策略是有一些参考价值的。这个数据还很粗糙,因为我们的原始数据的信息量并不大。不过如果我们继续收集不同天的数据,并进一步分析游客在不同日期、不同地点的人数情况,或者对游客的性别进行分析,我们就可能得到更多有趣的信息,但究竟能获得什么样的有用信息,还要看原始数据情况和我们实际着手进行迭代的方式。
总之,当我们把更多的信息集中在一起的时候,我们就能做更多的事情了,比如,我们可以分析游客数和当月平均气温的关系,或者分析交通趋势和公园游客趋势的关系,以及分析天气与游园游客性别的相关性等等,并且我们还能利用这些数据来帮助公园后续的建设和管理决策。
## 要点总结
这节课我们讲了数据可视化的一般过程它是一个需要反复迭代的过程而且在每一轮迭代中我们都可以以这4个问题作为参照分别是你有什么数据你想从数据中了解什么信息你想用哪种可视化方式呈现你看见了什么它有意义吗
按照这四个问题,我们在迭代过程中可以用分类的方式来处理数据,对数据分类,是最常用的处理原始数据的方式,也符合我们的思维习惯。数据的分类可以带来信息结构化,从而帮助我们提取想要的内容。也就能一步步达成我们想要的结果,然后我们再逐步细化迭代,就能做出更好的可视化效果来。
最后,我还想再强调一下,从本质上来说,可视化过程是对数据进行分析、提取有效信息、设计展现形式的不断迭代过程,今天我们讲的例子虽然简单,但更加复杂的例子,也无外乎是同样的处理过程,只不过那个时候,我们可能需要处理更多的数据,经过更多轮迭代。
## 小试牛刀
我们的原始数据里有性别数据,你可以根据性别数据进行过滤,修改上面的例子,看看按照性别分类的游客趋势图与不按性别分类的游客趋势图有什么不同吗?或者,除了折线图,你能把每个时间段的游客性别比例用并饼状图显示出来吗?以及,你还可以试着按照公园地点进行分组,再显示更细分的饼图效果。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码GitHub仓库](https://github.com/akira-cn/graphics/tree/master/data/park-people)

View File

@@ -0,0 +1,282 @@
<audio id="audio" title="34 | 数据处理(二):如何处理多元变量?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/28/196bdd87cf3a1af3b4dbe228cf608c28.mp3"></audio>
你好,我是月影。
数据处理是一门很深的学问,想要学好它,我们不仅需要掌握很复杂的理论,还需要不断地积累经验。不过,其中也有一些基础的数据处理技巧,掌握它们,我们就能更好地入门可视化了。
比如我们上节课重点讲解的数据分类就是其中一种非常基础的数据处理技巧也是数据处理的第一步。这一节课我会以处理2014年北京市的天气历史数据为例来和你进一步讨论数据处理的基础技巧包括从数据到图表的展现以及处理多元变量的方法。
## 从数据到图表展现
一般来说,我们拿到的原始数据通常可以组织成表格的形式,表格中会有很多列,每一列都代表一个变量。比如,我们拿到的这份天气历史数据,它看起来可能是下面这样的:
<img src="https://static001.geekbang.org/resource/image/24/48/24b87b9076ab5df4c9c8ed486a6dc948.jpg" alt="">
这里有许多变量,比如时间、最高气温、平均气温、最低气温、最高湿度、平均湿度、最低湿度、露点等等。一般的情况下,我们会将其中我们最关心的一个变量平均气温,用一个图表展现出来。具体怎么做呢?我们可以来动手操作一下。
这份数据是csv格式的是一张表我们先用D3.js将数据读取出来然后结构化成JSON对象。
```
const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData);
const dataset = data.filter(d =&gt; new Date(d.Date).getMonth() &lt; 3)
.map(d =&gt; {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](https://www.qcharts.cn/)它是一个基于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 =&gt; '');
const toolTip = new Tooltip({
title: arr =&gt; {
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]);
```
这样,我们就将图表渲染到画布上了。
<img src="https://static001.geekbang.org/resource/image/3c/fd/3c35aed4df29c2617d6d877fed588ffd.jpg" alt="">
## 处理多元变量
刚才我们已经成功将平均气温这个变量用折线图展示出来了,但在很多数据可视化场景里,我们不只会关心一个变量,还会关注多个变量,比如,我们需要同时关注温度和湿度数据。那怎么才能把多个变量绘制在同一张图表上呢?换句话说,同一张图表怎么展示多元变量呢?
### 在一张图表上绘制多元变量
最简单的方式是直接在图表上同时绘制多个变量,每个变量对应一个图形,这样一张图表上就同时显示多个图形。
我们直接以刚才的代码为例,现在,我们修改例子中的代码,直接添加平均湿度数据,代码如下:
```
const rawData = await (await fetch('beijing_2014.csv')).text();
const data = d3.csvParse(rawData).filter(d =&gt; new Date(d.Date).getMonth() &lt; 3);
const dataset1 = data
.map(d =&gt; {
return {
value: Number(d['Temperature(Celsius)(avg)']),
date: d.Date,
category: '平均气温'}
});
const dataset2 = data
.map(d =&gt; {
return {
value: Number(d['Humidity(%)(avg)']),
date: d.Date,
category: '平均湿度'}
});
```
然后我们修改图表的数据将温度dataset1和湿度dataset2数据都传入图表代码如下
```
chart.source([...dataset1, ...dataset2], {
row: 'category',
value: 'value',
text: 'date'
});
```
这样,我们就得到了同时显示温度和湿度数据的折线图。
<img src="https://static001.geekbang.org/resource/image/8a/f5/8a988aff89201fabea2d63629663ebf5.jpg" alt="">
### 用散点图分析变量的相关性
不过你应该也发现了把温度和深度同时绘制到一张折线图之后我们很难直观地看出温度与湿度的相关性。所以如果我们希望了解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 =&gt; {
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) =&gt; '温度与湿度:',
formatter: (data) =&gt; {
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]);
```
<img src="https://static001.geekbang.org/resource/image/de/01/dec1994d0a99794182f581924d17ef01.jpg" alt="">
从这个图表我们可以看出,平均温度和相对湿度并没有相关性,所以点的空间分布比较随机。事实上也是如此,气温和绝对湿度有关,但相对湿度因为已经考虑过了温度因素,所以就和气温没有相关性了。
那你可能会有疑问,相关的图形长什么样呢?我们可以用另外两个变量,比如露点和平均温度,来试试看能不能画出相关的散点图。
我们先来说说什么是露点。在空气中水汽含量不变, 并且气压一定的情况下, 空气能够冷却达到饱和时的温度就叫做露点温度, 简称露点, 它的单位与气温相同。
从定义里我们知道,露点和温度与湿度都有相关性。接下来,我们来看一下露点和温度的相关性在散点图中是怎么体现的。很简单,我们只要修改一下上面代码里的数据,把平均湿度换成平均露点温度就行了。
```
const dataset = data
.map(d =&gt; {
return {
temperature: Number(d['Temperature(Celsius)(avg)']),
tdp: Number(d['Dew Point(Celsius)(avg)']),
category: '平均气温与露点'}
});
```
这样,我们最终呈现出来的散点图具有典型的数据正相关性,也就是说图形的点更集中在对角线附近的区域。
<img src="https://static001.geekbang.org/resource/image/48/1a/483712c909fcfd962072a2c19caaeb1a.jpg" alt="">
我们还可以把湿度数据也加上。
```
const dataset = data
.map(d =&gt; {
return {
value: Number(d['Temperature(Celsius)(avg)']),
tdp: Number(d['Dew Point(Celsius)(avg)']),
category: '平均气温与露点'}
});
const dataset2 = data
.map(d =&gt; {
return {
value: Number(d['Humidity(%)(avg)']),
tdp: Number(d['Dew Point(Celsius)(avg)']),
category: '平均湿度与露点'}
});
...
chart.source([...dataset, ...dataset2], {
row: 'category',
value: 'value',
text: 'tdp'
});
```
我们发现,平均湿度和露点也是成正相关的,不过露点与温度的相关性更强,因为散点更集中一些。
<img src="https://static001.geekbang.org/resource/image/6d/86/6dcd043da6c8338865313105733f5286.jpg" alt="">
为了再强化理解,我们还可以看一组强相关的数据,比如平均温度和最低温度,你会发现,图上的散点基本上就在对角线上。
<img src="https://static001.geekbang.org/resource/image/2b/07/2b2c8f25ed11aeb7a00aac436a2f4e07.jpg" alt="">
总的来说,两个数据的散点越集中在对角线,说明这两个数据的相关性越强,当然这么说还不够严谨只是方便我们记忆而已。这里,我找到了一张散点图和相关性之间关系的总结图,你可以多了解一下。
<img src="https://static001.geekbang.org/resource/image/44/61/44ac95977e2143d67c079ec10a9c7661.jpg" alt="">
### 散点图的扩展
通过前面的例子,我们可以看到,用散点图可以分析出数据的二元变量之间的相关性,这对数据可视化场景的信息处理非常有用。不过,散点图也有明显的局限性,那就是它的维度只有二维,所以它一般只能处理二元变量,超过二维的多元变量的相关性,它处理起来就有些力不从心了。
不过,我们还不想放弃散点图在相关性上的优异表现。所以在处理高维度数据时,我们可以对散点图进行扩展,比如引入颜色、透明度、大小等信息来表示额外的数据维度,这样就可以处理多维数据了。
举个例子,我在下面给出了一张根据加州房产数据集制作的散点图。其中,点的大小代表街区人口数量、透明度代表人口密度,而颜色代表房价高低,并且加上经纬度代表点的位置。这个散点图一共表示了五维的变量(经度、纬度、人口总数、人口密度、房价高低),将它们都呈现在了一张图上,这在一定程度上表达了这些变量的相关信息。
[<img src="https://static001.geekbang.org/resource/image/bc/61/bcbf60ff83cf90a4d5e05d4f78b2b161.jpeg" alt="" title="图片来源:知乎">](https://zhuanlan.zhihu.com/p/141118125)
这里,我带你做个简单的分析。从这张图上,我们很容易就可以得出两个结论,第一个是,房价比较高的区域集中于两个中心,并且都靠近海湾。第二个是房价高的地方对应的人口密集度也高。
这就是用散点图处理多维数据的方法了。
### 其他图表形式
事实上,处理多维信息,我们还可以用其他的图表展现形式,比如用晴雨表来表示数据变化的趋势就比较合适。北大可视化实验室在疫情期间就制作了一张疫情数据晴雨表,你能明显看出每个省份每天的疫情变化。如下所示:
[<img src="https://static001.geekbang.org/resource/image/48/93/4837642324296791cd00230938bc2e93.jpg" alt="" title="图片来源mp.weixin.qq.com">](https://mp.weixin.qq.com/s/Nq0-p6z1GO869XaS0zliiw)
再比如,还有[平行坐标图](https://ww2.mathworks.cn/help/stats/examples/visualizing-multivariate-data.html)。平行坐标图也有横纵两个坐标轴并且把要进行对比的五个不同参数都放在了水平方向的坐标上。在下面的示意图中绘制了所有4缸、6缸或8缸汽车在五个不同参数变量上的对比。
<img src="https://static001.geekbang.org/resource/image/23/26/23948ddb9761907d3fd1316c506d3726.jpeg" alt="" title="左为平行坐标图,右为热力图">
此外,我们还可以用[热力图](https://www.jianshu.com/p/a575e53bcaa9)、[三维直方图](https://www.jianshu.com/p/a575e53bcaa9)、[三维气泡图](http://https://zhuanlan.zhihu.com/p/147243101)等等其他的可视化形式来展现多维度的信息。
<img src="https://static001.geekbang.org/resource/image/d8/1e/d805e7f932c3403c9732fec8bae1141e.jpeg" alt="" title="左为三维直方图,右为三维气泡图">
总之,这些数据展现形式的基本实现原理,我们都在前面的视觉篇中讲过了。在掌握了视觉基础知识之后,我们就可以用自己想要的呈现形式,自由发挥,设计出各种直观的、形式有趣的图表了。
## 要点总结
这一节课,我们主要讨论数据到图表的展现以及如何处理多元变量。
在数据到图表的展现中我们首先用d3.js把原始数据从csv中读取出来然后选择我们需要的数据用简单的图表库比如使用QCharts图表库进行渲染。渲染过程可以分为4步分别是创建图表对象 Chart并传入数据创建图形Visual创建坐标轴、提示和图例把图形、坐标轴、提示和图例添加到图表对象中完成渲染。
在处理多元变量的时候,我们可以用散点图表示变量的相关性。对于超过二维的数据,我们可以扩展散点图,调整颜色、大小、透明度等等手段表达额外的信息。除了散点图之外,我们还可以用晴雨表、平行坐标图、热力图、三维直方图、气泡图等等图表,来表示多维数据的相关性。
到这里,我们用两节课的时间讲完了可视化的数据处理的基础部分。这部分内容如果再深入下去,就触达了数据分析的领域了,这也是一个和可视化密切相关的学科。那我也说过,可视化的重点,一是数据、二是视觉,视觉往深入研究,就进入渲染引擎、游戏等等领域,数据往深入研究,就进入数据分析的领域。所以,在可视化的入门或者说是基础阶段,掌握我现在讲的这些基础知识就够了。当然,如果你想深入研究也是好事,你可以参考我在课后给出的文章好好阅读一下。
## 小试牛刀
<li>
我在GitHub代码仓库里放了两份数据一份是我们今天讲课用到的另一份是[2013到2018年的全国各地空气质量数据](https://github.com/akira-cn/graphics/blob/master/data/weather/2013-2018.csv)。你能把2014年北京每日PM2.5的数据用折线图表示出来吗你还能结合这两份数据用散点图分析平均气温和PM2.5指数的相关性吗?
</li>
<li>
你可以模仿我北大可视化实验室的疫情晴雨表实现一个2018年全国各城市空气质量晴雨表吗
</li>
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码见GitHub仓库](https://github.com/akira-cn/graphics/tree/master/data/weather)
## 推荐阅读
[1] [从1维到6维-多维数据可视化策略](https://zhuanlan.zhihu.com/p/147243101)
[2] [QCharts](https://www.qcharts.cn/)

View File

@@ -0,0 +1,339 @@
<audio id="audio" title="35| 设计(一):如何让可视化设计更加清晰?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/0e/fe642bb08a0004e297395d36c9faef0e.mp3"></audio>
你好,我是月影。
在实际的可视化项目中,我们经常会遇到一种情况:用户期望所有的可视化图表都是简单明了的。实际上,这是不现实的。
因为我们拿到原始数据之后,第一步是分析数据,也就是从各种不同的角度尝试去观察数据,确定我们希望用户了解的信息。这些信息如果是简单清晰的,那么可视化结果就是简单直观的。如果用户想要了解的数据规律本身就很复杂,那么可视化图表所能做的事情,也只能是尽可能清晰地展现用户关注的重要信息,屏蔽干扰信息,来降低用户理解数据的难度。
因此,我们要明白,在任何时候,制作可视化图表都是为了帮助人们理解抽象的数据,不管这些数据多复杂,都要尽可能让读者快速理解。如何才能做到这一点呢?简单来说,就是你要学会了解人们是怎样看数据的,然后将数据呈现得符合人们的思维和理解习惯。
接下来,我们就通过几个例子来学习一下,都有哪些方法可以轻松地把人们的注意力集中在数据信息上。
## 分清信息主次,建立视觉层次
我们可以先想这么一个问题:第一次看图表的时候,你都会注意哪些信息?如果是我的话,我总会试图在图表上找到什么有趣的东西。实际上,在看任何东西的时候,我们的眼睛总是倾向于识别那些引人注目的东西,比如,明亮的颜色,较大的物体,有特点的符号等等。因此,我们做可视化的时候,应当用醒目的颜色突出显示数据,把被淡化的其他视觉元素当作背景。其实,这就是我们今天要讲的第一个方法,建立视觉层次。
我们以上一节课平均温度与露点的散点图为例,来说说这具体是怎么做。
<img src="https://static001.geekbang.org/resource/image/28/b4/28084f718e2b89ebcd335d695f1e5eb4.jpg" alt="">
我们知道,一个可视化图表应该包括图形、图例、提示信息、坐标轴等等元素。其中,图形是最重要的元素,所以它一般会在图表最核心的区域呈现。上图中,我们就使用了比较鲜明的蓝色来突出图形。至于左侧和下方的坐标轴,我们就用比较淡的灰黑色来显示。背景中的辅助线存在感最弱,因为它们是用来辅助用户更认真地阅读图表、理解数值的,不是主要元素,所以我们会用非常淡的颜色把它们显示在背景里。这些元素就构成了一个有鲜明视觉层次感的图表。
不过,就这个图表而言,我们还可以把它做得更好。因为,我们实际上希望表达给用户的信息还包含了平均气温与露点的正相关性,如果用户对这个数学关系比较敏感,完全可以通过散点分布了解到它们的正相关性,但是对其他不敏感的用户来说,我们可以添加曲线图来引导他们。接下来,我们一起来看具体的做法,从中你就可以体会到建立视觉层次的思路了。
第一步,我们处理一下数值,将数据按照气温高低排序,然后对相同温度的数据进行分组,最后将相同温度下的平均露点温度计算出来。
```
// 露点排序
let dataset2 = [...dataset].sort((a, b) =&gt; a.tdp - b.tdp);
// 对相同露点的温度进行分组
dataset2 = dataset2.reduce((a, b) =&gt; {
let curr = a[a.length - 1]
if (curr &amp;&amp; curr.tdp === b.tdp) {
curr.temperature.push(b.temperature)
} else {
a.push({
temperature: [b.temperature],
tdp: b.tdp
})
}
return a
}, []);
// 最后将露点平均温度计算出来
dataset2 = dataset2.map(d =&gt; {
d.category = '露点平均气温'
d.temperature = Math.round(d.temperature.reduce((a, b) =&gt; 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(&quot;平均气温与露点&quot;);
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' };
});
```
<img src="https://static001.geekbang.org/resource/image/b3/38/b3b23baea83a11635ece2ec92911fc38.jpg" alt="">
这个图表分为三个层次:第一层,我们用曲线描绘气温与平均露点的关系,随着气温升高,平均露点温度也会升高,二者的总体趋势保持一致。第二层,就是我们保留的散点图,我们可以通过它看出具体某一次记录的温度和露点数据,还可以从分布规律看出相关性的强度,因此它能够表达的信息比曲线图还要多一些。第三层,我们使用了坐标轴和辅助线作为背景。总之,像这样层次分明的图表,非常有助于我们快速理解图表上的信息。
## 选择合适图表,直观表达信息
建立视觉层次是第一种集中注意力的方法,第二种方法其实和它有点类似,就是用合适的图表更加直观地去表达信息。
还记得我们在32节课中讲的公园游客散点图吗我们将男游客和女游客分别标记之后可以看到不同区域在某个时刻的游客性别分布比如下图就是公园12点游客分布情况。
<img src="https://static001.geekbang.org/resource/image/51/be/5138f18cc2c7f86fba3ff77d9d564cbe.jpeg" alt="" title="公园12点游客散点图">
不过,散点图虽然能够一眼看出不同性别游客在四个区域的大致分布,但不够直观。如果我们要更精细地分析的话,还是应该对数据呈现方式进行改进。
在表达某个单组变量的分布状况的时候,使用饼图是一个比较直观的方式。
比如我们可以用一组饼图来表示中午12点公园游客在四个区域的性别分布让它对应上面那张散点图。
第一步是处理数据。我们就以给12点的游客分类为例。我们根据游客所在的区域和性别来统计人数对应到下面的代码就是countPeople函数中实现的逻辑。
```
function count(d, dataset) {
let place;
if(d.x &lt; 300 &amp;&amp; d.y &lt; 300) {
place = 'square';
} else if (d.x &gt;= 300 &amp;&amp; d.y &lt; 300) {
place = 'sections';
} else if (d.x &gt;= 300 &amp;&amp; d.y &gt;= 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 &lt; data.length; i++) {
const d = data[i];
if(d.time === 12) {
const p = count(d, dataset);
}
}
return dataset;
}
const dataset = groupData(data);
```
经过这么分类之后,现在的数据集看起来就是下面这样:
<img src="https://static001.geekbang.org/resource/image/20/52/20aa36362508cb80f5c80bd0dc38e352.jpg" alt="">
第二步我们用qchars来创建四个饼图。在图中我们用橙色表示女游客绿色表示男游客。这样我们就能明显看出四个区域中男、女游客的分布情况了。具体的代码如下
```
const { Chart, Pie, Legend, Tooltip, theme} = qcharts;
theme.set({
colors: ['#71dac7', '#d57a77'],
});
Object.entries(dataset).forEach(([key, dataset]) =&gt; {
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]);
});
```
<img src="https://static001.geekbang.org/resource/image/bd/b8/bd61e696aaee79304f7ef69d65b74db8.jpeg" alt="">
虽然饼图表示的结果非常简单和直观,但它也有缺点,就是一张饼图实际上能表示的信息很少。一个饼图一次只能表示一组单维度数据,而性别又只有男和女两种,所以我们为了表示四个区域,不得不用四张饼图,这会非常麻烦。
那我们可以尝试用一张图表来表示更多维度的信息吗?这当然可以。我们可以尝试把前面的四张饼图合并成一张**嵌套饼图**,它由两个饼状图组成,中间小的饼状图是女性在四个区域的分布情况,大的饼图是男性在四个区域的分布情况。
<img src="https://static001.geekbang.org/resource/image/77/93/7787c6b16acd23dc3272a7e5227eb893.jpeg" alt="">
它的具体实现方法是首先我们在前面groupData的基础上对数据进行进一步处理将数据对象扁平化然后添加place属性。
```
const dataset = [];
Object.entries(groupData(data)).forEach(([place, d]) =&gt; {
d[0].place = `${place}: 男`;
d[1].place = `${place}: 女`;
dataset.push(...d);
});
```
这样处理数据之后,新的数据结构如下:
<img src="https://static001.geekbang.org/resource/image/e3/4d/e3fa5dc8468bd6907115651074997c4d.jpg" alt="">
然后,我们可以在这个数据结构的基础上绘制两个饼图,代码如下:
```
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 =&gt; d.gender === '女游客').map(d =&gt; d.place)));
const pie2 = new Pie({
innerRadius: 0.5,
radius: 0.7
}).source(ds.selectRows(dataset.filter(d =&gt; d.gender === '男游客').map(d =&gt; 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]) =&gt; {
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(&quot;pillar&quot;, {
strokeColor: &quot;#FFF&quot;,
lineWidth: 1,
});
const tooltip = new Tooltip();
const legend = new Legend({ orient: 'vertical', align: ['right', 'center'] });
chart.append([bar, tooltip, legend]);
```
<img src="https://static001.geekbang.org/resource/image/cc/35/cc13d019daa4df3b52b844cc988f4435.jpeg" alt="">
具体的方法讲完了,我们做个总结。使用南丁格尔玫瑰图,我们能把人群在公园区域的分布和性别分布规律显示在一张图上,让更多的信息呈现在一张图表上。这既能节省空间,也便于人们高效率地获取更多信息。但是,太多的信息聚集也会显著增加图表复杂度,减少图表的直观程度。就像这张南丁格尔图一样,它虽然简单,但直观性仍然不如之前用四个饼图和一个嵌套饼图的表达形式。所以在我们实际可视化项目中,需要根据实际情况选择合适的解决方案,大部分情况下,我们需要在直观性和信息聚集程度上做一个取舍。
## 改变图形属性,强化数据差异
除了直观表达信息外,我们还可以采用一些其他的手段,比如,改变颜色、大小、形状等等,以此来强化数据之间的差异,这也是增强可视化图表中信息表达的一种手段。
这次我们就不具体实现了,直接来看一个成熟的例子。比如说,北大可视化实验室设计的全国新冠病毒肺炎疫情晴雨表,就是使用颜色和方块大小,将增量数据很直观地表达出来,从而宏观地呈现出疫情的发展态势,这对于揭示疫情拐点来说非常有帮助。
<img src="https://static001.geekbang.org/resource/image/2a/6b/2a7d805c3b787bd47c42da227b8c746b.jpg" alt="">
颜色和面积、折线图的方向、直方图的高度差,这些方式都能比较鲜明地体现出数据之间差异。除此之外,我们在绘制图表的时候,还可以使用背景网格线,来辅助用户观察数据间的差异,发现数据之间的变化规律。
说到强调数据差异,我还想给你说一种比较完美的图表,它就是股市中常用的蜡烛图,又叫做**K线图**。<br>
<img src="https://static001.geekbang.org/resource/image/40/4c/405837d612ae534b634bb3ae0d05c74c.jpeg" alt="">
我要先说明一点我不鼓励任何人进行投机性的炒股。但不得不说股市里的K线图是一个非常成功的可视化案例这个图表用颜色来强化数据的增减还包含了许多其他有用的信息让想要了解股市市场的人能够从中分析出商品或者股票的价格走势再做出相应的决策。
总之,从这些优秀的案例中我们知道,在可视化实现中,我们应该重视数据之间的比较,用一些图形属性来强调数据的差异,这对加强图表的表现力非常有效。
## 要点总结
这节课,我们学习了让可视化设计更加清晰的三种方法。首先,我们应该学会建立视觉层次,信息有主次之分,我们要把重要的信息突出显示,减少次要信息对比,以及干扰信息的存在感。
其次,我们要学会用合适的图表来直观地表达信息,比如说,一般情况下,我们会用饼图来描述变量值的分布,用嵌套饼图来展现更多信息,用南丁格尔玫瑰图聚合多维度信息。
最后,我们还应该重视数据之间的比较。大部分情况下,我们会使用一些图形属性,比如更改图形颜色、形状等等,来强调数据之间的差异,这样能够增强图表的表现力。
总的来说,可视化设计的许多方法,其实都是源于实践经验的积累。想要学好,真的没有捷径可走,今天我讲的这三种方法也都是特例。学会它们之后,你还是要多练习,争取做到举一反三,这样才能在可视化设计这条路上走得更远。
## 小试牛刀
你可以利用我放在[GitHub](https://github.com/akira-cn/graphics/tree/master/data/weather)仓库里的天气和空气质量数据,设计出可视化案例,展现出和散点图不一样的内容吗?比如说,你可以让图表展现不同月份下,晴天和雾霾天的分布率。
当然,如果你有更好的创意,我非常鼓励你把它们分享出来,期待在留言区看到你的作品。今天就讲到这里,我们下节课见!
## 源码
[示例代码详见GitHub仓库](https://github.com/akira-cn/graphics/tree/master/data)

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="36 | 设计(二):如何理解可视化设计原则?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/7e/0ff97f142cb6975efce0f0b5423bd77e.mp3"></audio>
你好,我是月影。
工作中,很多新人在进行可视化设计的时候,往往因为想要表达的内容太多,或者不知道该怎么突出重点信息,而设计不出好看的图表。所以他们总会来问我,有没有可以用来参考的设计原则啊?
今天,我就把我从实战中总结出的一些经验,整理出的基本设计原则分享给你,主要有四个,分别是简单清晰原则、视觉一致性原则、信息聚焦原则,以及高可访问性原则。接下来,我们就一一来说。
## 简单清晰原则
当我们刚开始进行可视化设计的时候总会认为,只有用尽可能多的数据,才能做出十分酷炫的效果。但实际上,可视化真正的价值并不是这些看上去酷炫的效果,而是准确地表达信息内容。一味地堆砌数据,只会让真正有用的信息淹没在干扰信息里,从而影响人们解读信息中真正有价值的内容。
因此,简单清晰原则是可视化设计中最重要的一条基本原则。下面,我们来看一个反面例子。
[<img src="https://static001.geekbang.org/resource/image/49/6d/490ac56e5fc4198acc68e5d0c703e86d.jpeg" alt="" title="图片来源cloud.tencent.com">](https://cloud.tencent.com/developer/article/1092742)
这是国际茶叶委员会制作的全球茶叶消费排行榜图表,这张图表本身其实问题不是很大。不过,如果引用这张图表是为了说明喝茶最多的不是中国人,就有点太复杂了。我相信你第一眼甚至看不到中国的数据在哪,就更别说看懂这张图了。
另外这张图底部的直方图表示消费上方的连线和最上面的横条表示什么也不够清晰。如果我们仔细研究还是可以看出最上方的横条表示茶叶产量其中33.7%供应国内中国人均只消费了566克茶叶。由此我们可以得出的结论是中国是产茶大国但茶叶消费数量不是最多的茶叶更多会出口给印度、越南、土耳其和欧洲的一些国家。
那这张该怎么优化呢?首先,我们要明确这张图到底要让我们知道什么。如果这张图想让我们知道中国产茶和茶叶销量的结论,那么我们可以把中国的部分用更加醒目的颜色标记出来。如果只需要研究茶叶消费,那么我们可以完全忽略上面关于生产的部分,直接用更简单的销量直方图展示图表结果。
其次,我们完全不需要列出这么多的国家,列出更少的国家可以让用户更快地抓住重点。所以我们直接用下面的简单的直方图表达,就可以表示我们要表达的内容了。
<img src="https://static001.geekbang.org/resource/image/bd/ff/bd1076146c0b00ba49b818cfyya0e6ff.jpeg" alt="">
如果想把产茶的信息也包含进来,我们可以再增加一个饼图。
<img src="https://static001.geekbang.org/resource/image/4a/03/4a9a6d569bdea90d44218947689e2603.jpeg" alt="">
这两张图加起来已经足够明确地表示出我们想要表达的信息了,也比原始的图表更加简单直观。当然,你也可能觉得还是原始的图信息量大,显得更加有用,这一点就见仁见智了,可视化设计的好坏本来就没有定论,原则也只是参考。
## 视觉一致性原则
几乎在前面的所有实战案例里,我都强调过颜色的运用。在可视化中,颜色对于强化信息有着非常大的帮助。配色良好的图表,不仅看起来赏心悦目,也能帮助我们快速定位到想要关注的信息。
虽然严格上来说配色是UI设计师该负责的事儿但是作为可视化工程师我们也应该了解一些基本的配色原则。一般来说有两种比较常用的配色方案分别是互补色方案和同色系方案。
第一种配色方案是,当想要突出数据之间的差异时,我们可以用**互补色**来增强对比效果。那么什么是互补色呢?你还记得我们在[第10节课](https://time.geekbang.org/column/article/260922)里学过的色彩表示吗其中HSV颜色表示法是用色相HUE、饱和度Saturation和明度Value来表示色值的。所谓的互补色就是指在饱和度和明度相同的情况下色相值相差180度的一对颜色。因为互补色色相差距最大所以它们并列时会产生强烈的视觉对比效果这样能够起到强调差异的作用。
当然我们实际进行数据对比的时候并不会严格要求两个颜色是差异180度的互补色而是会采用差异较大的差值比较接近180度的两种颜色这样也算是互补色。下面这张图就使用了红、绿互补色的配色方案。
<img src="https://static001.geekbang.org/resource/image/1d/bf/1d7b028610e569f539bf86fdef5b70bf.jpeg" alt="">
另外一种配色方案是采用同色系,利用不同深浅的同色系颜色来表示不同的数据。同色系方案的对比没有这么强烈,它从视觉上给人的感觉更柔和,而且色彩的一致也能够减少我们看图表时的视觉疲劳,从而让人保持注意力集中,帮助我们理解图表信息。下面这张图就采用同色系配色方案。
<img src="https://static001.geekbang.org/resource/image/82/9b/82456812e205a9bb737d72efd272be9b.jpeg" alt="">
在具体项目中,使用互补色还是同色系方案不是绝对的,我们要看具体的应用场景:如果你想要突出数据项之间的差异,那么采用对比色方案;如果你想要让人长时间关注,尤其是一些复杂的大型图表,那么采用同色系方案就是更好的选择。
## 信息聚焦原则
在前面的课程中,我们已经知道,长度、高度、大小、形状、颜色、透明度等等都可以用来表示变量。这样,我们就能在一张图上表示多元变量,同时我们改变这些属性还可以让信息更加聚焦。
比如,下面这张示例图表就是一个非常好的[例子](https://echarts.apache.org/examples/zh/editor.html?c=wind-barb)。
<img src="https://static001.geekbang.org/resource/image/f2/55/f23c22f1311458875f6fe56d33146855.jpeg" alt="">
这是一张天级预报的图表图表上同时显示了温度、天气、风向、风速、浪高这些变量每个变量都采用了不同的形式来展示区分度很好内容非常清晰也很聚焦。另外图表上的除了用不同的y轴高度来表示风速之外还采用红色、黄色、绿色这三种颜色标记了不同的风速等级这就是用两种方式表示同一个元素。
一般来说,我们用一个图属性来表示一个变量,比如箭头方向表示风向,高度表示风速,但如果我们要强调某个变量的时候,我们也可以用超过一个属性来表示,比如用颜色和高度同时表示风速,这就起到了强调的作用。类似的[示例](https://echarts.apache.org/examples/zh/editor.html?c=bar3d-punch-card&amp;gl=1)还有下面这张图表。
<img src="https://static001.geekbang.org/resource/image/70/88/7010097cc45c45e353f7ae1b4a425188.jpeg" alt="">
在这张图表上,为了强调产品的生产量,我们用立方体的高度和颜色同时表示了这一个维度的变量。
总之,我们可以将相关的多元变量聚合在一张图表上,用来更聚焦地表达多元信息。不过,这么做的时候,我们要确定我们需要的信息真的包括了这些多元变量,并且它们彼此是有相关性的,否则我们还是应该考虑将它们拆分或者过滤掉无用的信息,这样才不违背前面的简单清晰原则。
## 高可访问性原则
如果你是一名前端工程师,我相信你应该听过网页设计的可访问性,或者说无障碍原则。因为可视化本身和前端密不可分,所以在可视化设计中,我们也同样需要考虑设计高可访问性。
可视化的无障碍设计主要体现在色彩系统上。要知道我们的用户可能包含视觉障碍人群而且我们的图表可能呈现在不同的设备上从高端的4K显示屏、普通液晶显示器到投影仪、彩色打印机或者黑白打印机。
[<img src="https://static001.geekbang.org/resource/image/a2/52/a2d457yy89d62d5dfc96108b5fbe4252.jpeg" alt="">www.woshipm.com](http://www.woshipm.com/pd/719815.html)
因此,即使我们设计的颜色在我们看来已经足够有差异性了,也可能在一些低色彩分辨率的设备上表现得不那么友好,甚至会给视觉障碍人士带来困扰。
这就要求我们在设计上要尽量避免对视觉障碍人士不太友好的配色,比如,用黄色和黄绿色来区分内容。在使用同色系配色方案的时候,我们也要注意色彩在明亮度和饱和度上要有足够的差异,以便于在黑白打印机等设备上打印出来的图表也有足够的区分度。
还有一种情况是,如果重要的变量要依赖于颜色,而你又不能保证色彩的可访问性时,采用前面的方法,用其他属性和颜色一起来表示目标变量,这样既能够起到强调作用,也预防了单纯使用颜色对视觉障碍人士带来困扰。
除此之外我们还可以用一些色彩检查工具来辅助配色。在Photoshop中选择**视图-&gt;校样设置-&gt;红色盲型/绿色盲型**之后就能看到我们设计的图表颜色在视觉障碍人群眼中的效果了。如果你不怎么用Photoshop还可以访问 [color-blindness.com](https://www.color-blindness.com/coblis-color-blindness-simulator/)的在线服务,通过上传图片来检查你的配色。
除了颜色之外,文字提示信息也需要考虑可访问性。首先,提示字体的大小要适中,并且足够清晰。其次,要对于老年人和视力不好的人群提供缩放字体的功能,这样能够在很大程度上改善可访问性。
虽然网页本身提供了文字内容缩放的功能但是图表库可能没有考虑文字缩放后的布局呈现。比如Echarts的这个图表如果我们放大浏览器上的文字它们就会叠在一起完全不可阅读这就是一个可访问性不太好的反面案例。
<img src="https://static001.geekbang.org/resource/image/bf/ff/bf59e80aa18e8035e1283165416974ff.jpeg" alt="">
总之,图表设计的目的是给人阅读的,我们只有在可访问性上下功夫,才能让更多的人读懂图表,更好地发挥出它的价值。
## 要点总结
这一节课,我们讨论了可视化设计的四个基本原则。
首先是简单清晰原则,要明确我们的图表要表达的信息,只呈现给用户希望让用户看到的内容,不要增加太多干扰项,保持图表的简洁清晰,有助于读者快速获得有用的信息。
其次是视觉一致性原则,在配色的时候,配色原则要统一。我们可以采用补色方案,也可以采用同色系方案,它们各有利弊。补色方案可以增强对比,在我们要强调数据差异性的时候,一般采用补色方案,而同色系方案在视觉上更柔和,不容易疲劳,在比较复杂的或者需要用户长时间关注的图表里,采用同色系方案是一种更好的选择。
第三是信息聚焦原则,我们可以用多个属性将多元信息聚集在一个图表里,让读者能够更高效率地获取信息。不过我们要确保信息确实是有关联的,需要且易于读者理解的,否则的话,我还是建议将它们拆分到不同图表,这样才不违背简单清晰的原则。
最后,图表设计要考虑可访问性,也就是无障碍原则。在配色上要考虑到视觉障碍人士,尽量不要让他们产生困扰,另外如果网页能支持文字的缩放功能,就会对老年人和视力不佳的人群更友好,从而让更多人读懂图表,更好地发挥出它的价值。
## 小试牛刀
在前面几节课中,我留下了一些练习题,不知道你都完成了吗?如果你完成了,你可以回顾一下自己实现的可视化图表,看看是否符合或者违反了我们今天讲的四个原则。如果违反了某些原则,你可以尝试去改进,再把改进前、后的对比结果分享到留言区。
如果你已经完成得非常好了没什么可改进了也可以用我在GitHub仓库提供的[两份数据](https://github.com/akira-cn/graphics/tree/master/data/weather),来试着实现符合这四个原则的高质量可视化图表。
这四个简单又实用的设计原则,你学会了吗?如果你的朋友正在为设计图表而困扰,欢迎你把这节课分享给他,说不定,问题就解决了呢?好了,期待在留言区看到你的作品。今天就讲到这里,我们下节课见!
## 推荐阅读
[Echarts 图表库](https://echarts.apache.org/zh/index.html)是一个不错的开源图表库,其中有许多[经典的例子](https://echarts.apache.org/examples/zh/index.html),我建议你认真看一下,想一想它们是否符合或违反了前面总结的四个原则,并试着学习、理解和使用这些图表。

View File

@@ -0,0 +1,368 @@
<audio id="audio" title="37 | 实战(一):如何使用图表库绘制常用数据图表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/77/246c9yya0d28e812d93fa9b8ce626377.mp3"></audio>
你好,我是月影。
图表是我们在可视化中展示数据常用的方式之一,常见的有柱状图、折线图、饼图等等,我们之前也都一一实现过。如果我让你总结一下图表的实现方法,你能说出来吗?总结不出来也没关系,这节课,我们就一起梳理一下实际项目中常用的制图方法。
实现图表有两种方式,一是使用现成的图表库,二是使用数据驱动框架,前者胜在简单易用,后者则更加灵活。所以,我们会用两节课的时间分别来讲,使用图表库和使用数据驱动框架的制图思路。
因为使用图表库更加简单,所以这一节课我们先来讲怎么使用它实现图表。另外,这节课的实践性比较强,我建议你跟着我的讲解一块儿动手去写,这样能更快地理解课程的内容。
## 课前准备
说了这么多我们今天到底会用哪些图表库呢我们主要会使用我们比较熟悉的SpriteJS和QCharts来绘制图表。其他的图表库例如更常用的[ECharts](https://echarts.apache.org/),它在图表实现上的原理和用法和我们今天讲的基本相同,所以学完了今天的内容,你完全可以根据它的教程文档去自学。
好了确定了要使用的工具之后我们就要配置和加载SpriteJS和QCharts。具体怎么做呢
最简单的方式是我们直接通过CDN用script标签来加载SpriteJS和QCharts打包好的文件。
```
&lt;script src=&quot;https://unpkg.com/spritejs/dist/spritejs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;https://unpkg.com/@qcharts/core@1.0.25/dist/index.js&quot;&gt;&lt;/script&gt;
```
如果是完整的工程项目的话你也可以使用webpack来打包和加载模块。这里有一个[Quick Start](https://github.com/qcharts/quickstart)你可以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]);
```
这样,我们就让图形显示出来了,效果如下:
<img src="https://static001.geekbang.org/resource/image/b6/9f/b6a87f9984082b6a02a99b484262ef9f.jpeg" alt="">
了解了QCharts图表的基本用法之后我们一起进入实践环节吧。
## QCharts绘制折线图、面积图、柱状图和饼图的方法
我们先来讲讲折线图、面积图、柱状图和饼图的实现方法,因为之前已经实现过很多次了,所以理解起来比较容易。
首先是折线图它是可视化中最常用的图表之一。刚才我们用Line图形绘制了一条折线但它还不是完整的折线图。完整的折线图除了有图形以外还要有表示数据的坐标轴、提示信息和图例这些都需要在QCharts中由插件来完成。
接下来,我们就继续给前面的代码添加元素。首先,我们给它增加两个坐标轴,分别是底部和左侧的。
我们通过Axis类来创建axis对象默认的坐标轴是在底部不用修改再通过.style改变它的样式属性比如我们将"grid"设置为false这样画布上不会显示纵向的网格线。
```
const axisBottom = new Axis().style(&quot;grid&quot;, false);
```
然后我们同样创建一个axis对象通过设置orient: “left” 让它朝左,这样就成功创建了左侧的坐标轴。同样,我们也要把它样式中的"axis"属性设置为false这样画布上就不会显示坐标轴的实线了。
```
const axisLeft = new Axis({ orient: &quot;left&quot; })
.style(&quot;axis&quot;, false);
```
最后我们将两个坐标轴添加到chart对象的子元素中去就可以将坐标轴显示出来。
<img src="https://static001.geekbang.org/resource/image/62/84/62c0556935f2756c6eac5a88057eb784.jpeg" alt="">
添加完坐标轴之后我们再添加图例Legend和提示Tooltip。最简单的方式是我们直接创建两个对象然后将它们添加到charts对象的子元素中。
```
const legend = new Legend();
const tooltip = new Tooltip();
chart.append([line, axisBottom, axisLeft, legend, tooltip]);
```
添加了图例和提示信息后的图表如下图所示:
<img src="https://static001.geekbang.org/resource/image/58/85/5896a90b8979ecaf081726195f632985.jpeg" alt="">
**接下来我们再来说说怎么用QCharts绘制面积图和柱状图。**
学会了折线图的绘制方法我们就可以用相同的思路非常简单地绘制其他图形了比如说我们可以简单将Line对象换成Area或Bar对象这样就可以用同样的数据绘制出面积图或柱状图。这是为什么呢
<img src="https://static001.geekbang.org/resource/image/2b/bf/2b29397b83cdbe7630bd88982d717cbf.jpeg" alt="" title="将 Line 改成 Area 绘制出面积图">
<img src="https://static001.geekbang.org/resource/image/c7/fd/c70fe007a4e74124a8e23afbb59175fd.jpeg" alt="" title="将 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]);
```
上面的代码用同样的数据绘制了一个饼图。
<img src="https://static001.geekbang.org/resource/image/12/7d/12dffaec3578995169de1db76491947d.jpeg" alt="">
## QCharts绘制雷达图、仪表盘和玉玦图、南丁格尔玫瑰图
讲完了这些常见的基础图表,我们再来看几个比较有趣的图表。
首先是雷达图,它一般用来绘制多组固定数量的数据,可以比较直观地显示出这组数据的特点。我们经常会在游戏中看见它的应用,比如,下面这张图表就显示了三国武将诸葛亮的各方面数据。
<img src="https://static001.geekbang.org/resource/image/96/ae/96b37e3a3267470ce6a95e3c358dcbae.jpeg" alt="">
雷达图的实现代码如下:
```
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) =&gt; ({ opacity: 0.3 }));
const legend = new Legend({ align: ['center', 'bottom'] });
const tooltip = new Tooltip();
chart.append([radar, legend, tooltip]);
```
除此之外,还有一些其他类型的图表,可以用来展示特殊的信息。比较典型的有仪表盘,它可以显示某个变量的进度。
<img src="https://static001.geekbang.org/resource/image/6a/9e/6a21ab85d811fa8a6abyy30ab0516b9e.jpeg" alt="">
仪表盘实现代码比较简单,如下所示:
```
const gauge = new Gauge({
min: 0,
max: 100,
percent:60,
lineWidth: 20,
tickStep: 10
});
gauge.style('title', { fontSize: 36 });
chart.append(gauge);
```
如果我们要显示多个变量的进度,还可以使用玉玦图。
<img src="https://static001.geekbang.org/resource/image/b7/82/b76efc4a8c052fb8d11ba976ef039782.jpeg" alt="">
对应的代码也非常简单,如下所示:
```
const data = [
{
type: '政法干部',
count: 6654
},{
type: '平安志愿者',
count: 5341
},{
type: '人民调解员',
count: 3546
},{
type: '心理咨询师',
count: 4321
},{
type: '法律工作者',
count: 3923
},{
type: '网格员',
count: 5345
}
].sort((a, b) =&gt; 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节课](https://time.geekbang.org/column/article/288323)已经说过,这里就不再多说了。如果你还不熟悉,可以再去回顾一下。
<img src="https://static001.geekbang.org/resource/image/47/a8/47eca31ddbf1cb2423aa3db23bcabca8.jpeg" alt="">
## QCharts绘制图表组合
我们刚才讲的都是在图表上绘制单一变量,那要想在图表上聚合多元变量,比如在一张天气图表上同时展示降水量、气温,我们可以同时绘制多个图形来表示。
我们可以分两种情况来讨论最简单的情况是用相同的图形来绘制不同的变量。这个时候我们可以直接通过不同category来绘制多个图形。比如下面的数据就是用两组category分别绘制了两条折线。
```
const data = [
{ date: &quot;1月&quot;, catgory: &quot;降水量&quot;, val: 15.2 },
{ date: &quot;2月&quot;, catgory: &quot;降水量&quot;, val: 19.2 },
{ date: &quot;3月&quot;, catgory: &quot;降水量&quot;, val: 11.2 },
{ date: &quot;4月&quot;, catgory: &quot;降水量&quot;, val: 45.2 },
{ date: &quot;5月&quot;, catgory: &quot;降水量&quot;, val: 55.2 },
{ date: &quot;6月&quot;, catgory: &quot;降水量&quot;, val: 75.2 },
{ date: &quot;7月&quot;, catgory: &quot;降水量&quot;, val: 95.2 },
{ date: &quot;8月&quot;, catgory: &quot;降水量&quot;, val: 135.2 },
{ date: &quot;9月&quot;, catgory: &quot;降水量&quot;, val: 162.2 },
{ date: &quot;10月&quot;, catgory: &quot;降水量&quot;, val: 32.2 },
{ date: &quot;11月&quot;, catgory: &quot;降水量&quot;, val: 32.2 },
{ date: &quot;12月&quot;, catgory: &quot;降水量&quot;, val: 15.2 },
{ date: &quot;1月&quot;, catgory: &quot;气温&quot;, val: 2.2 },
{ date: &quot;2月&quot;, catgory: &quot;气温&quot;, val: 3.2 },
{ date: &quot;3月&quot;, catgory: &quot;气温&quot;, val: 5.2 },
{ date: &quot;4月&quot;, catgory: &quot;气温&quot;, val: 6.2 },
{ date: &quot;5月&quot;, catgory: &quot;气温&quot;, val: 8.2 },
{ date: &quot;6月&quot;, catgory: &quot;气温&quot;, val: 15.2 },
{ date: &quot;7月&quot;, catgory: &quot;气温&quot;, val: 25.2 },
{ date: &quot;8月&quot;, catgory: &quot;气温&quot;, val: 23.2 },
{ date: &quot;9月&quot;, catgory: &quot;气温&quot;, val: 24.2 },
{ date: &quot;10月&quot;, catgory: &quot;气温&quot;, val: 16.2 },
{ date: &quot;11月&quot;, catgory: &quot;气温&quot;, val: 12.2 },
{ date: &quot;12月&quot;, catgory: &quot;气温&quot;, val: 6.6 },
];
chart.source(data, {
row: &quot;catgory&quot;,
value: &quot;val&quot;,
text: &quot;date&quot;,
});
const line = new Line({ axisGap: true });
...
```
<img src="https://static001.geekbang.org/resource/image/b8/92/b88746440cae3937beb3c638b9bda392.jpeg" alt="">
如果我们想用不同类型的图形来展示多个变量在QCharts中我们只需要创建多个不同的图形对象然后把它们都添加到chart对象的子元素中去就可以了。
```
const ds = chart.dataset;
const d1 = ds.selectRows(&quot;降水量&quot;);
const line = new Line({ axisGap: true })
.source(d1)
.style(&quot;point&quot;, { strokeColor: &quot;#fff&quot; });
const d2 = ds.selectRows(&quot;气温&quot;);
const bar = new Bar().source(d2);
bar.style(&quot;pillar&quot;, { fillColor: &quot;#6CD3FF&quot; });
```
如上面代码所示我们先可以通过chart.dataset拿到通过.source绑定给chart对象的数据集然后通过selectRows分别将降水量和气温数据过滤出来。接着我们分别创建Line和Bar两个图形对象再将降水量和气温数据分别绑定给它们。最后我们将这两个对象同时添加到chart子元素列表里就可以将两个不同类型的图形显示出来了具体的效果如下
<img src="https://static001.geekbang.org/resource/image/f8/62/f8688798100f4a9yyc04c9d34fc79262.jpeg" alt="">
## 要点总结
这节课我们主要学习了QCharts图表库的使用。
QCharts是基于SpriteJS的简单可视化图表库我们通过它可以绘制各种类型的图表。一般来说我们是先创建图表对象然后绑定数据接着添加图形对象以及其他的插件包括图例和提示。通过将图形和插件以子元素的形式添加到图表对象上就能把图表内容最终显示出来了。
我们可以很方便地根据数据特点和业务需要,用数据绘制折线图、面积图、柱状图、饼图、雷达图等图表,还可以绘制特殊的仪表盘和玉玦图。另外,如果要显示多维数据,我们也可以用稍微复杂一些的南丁格尔玫瑰图。
最后,我们还可以把多维度变量聚合在一个图表中来显示不同的图形组合。具体操作是,我们先筛选数据,然后创建不同类型的图形对象,最后将它们都添加到图表对象的子元素中。
总的来说我们今天讲的其实都是QCharts图表库最基础、最常用的方法。QCharts还提供了众多其他类型的图表以及灵活操作图表样式的API。如果你有兴趣继续钻研可以通过我课后给出的参考链接进一步学习。
## 小试牛刀
你学会了使用不同图表来表达不同数据了吗你可以试着使用GitHub仓库里的北京市天气数据和空气质量数据实现一个温度、湿度、风速、空气质量的聚合图表吗如果用来展示温度、湿度、风速和空气质量的图形都不相同就更好了。
这些常用的制图方法你都学会了吗?下节课,我们会接着来学,怎么使用数据驱动框架来表达不同数据,挑战才刚刚开始呢,我们下节课再见!
## 源码
课程代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/qcharts)
## 推荐阅读
[QCharts官网](https://www.qcharts.cn/#/home)

View File

@@ -0,0 +1,328 @@
<audio id="audio" title="38 | 实战(二):如何使用数据驱动框架绘制常用数据图表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/f0/2711de9972222c1ea5b9504809df6cf0.mp3"></audio>
你好,我是月影。
上一节课,我们使用图表库实现了一些常用的可视化图表。使用图表库的好处是非常简单,基本上我们只需要准备好数据,然后根据图形需要的数据格式创建图形,再添加辅助插件,就可以将图表显示出来了。
图表库虽然使用上简单但灵活性不高对数据格式要求也很严格我们必须按照各个图表的要求来准备数据。而且图形和插件的可配置性完全取决于图表库设计者开放的API给开发者的自由度很少。
今天我们就来说说使用数据驱动框架来实现图表的方式。这类框架以D3.js为代表提供了数据处理能力以及从数据转换成视图结构的通用API并且不限制用户处理视图的最终呈现。所以它的特点是更加灵活不受图表类型对应API的制约。不过因为图表库只要调用API就能展现内容而数据驱动框架需要我们自己去完成内容的呈现所以它在使用上就没有图表库那么方便了。
使用图表库和使用数据驱动框架的具体过程和差别,我这里准备了一个对比图,你可以看一下。
<img src="https://static001.geekbang.org/resource/image/a7/bc/a7ed3169666071df64de4e4bab239fbc.jpg" alt="" title="使用图表库(左)和使用数据驱动框架(右)渲染图表的流程对比">
不过这么讲还是比较抽象,接下来,我们还是通过绘制条形图和力导向图,来体会用数据驱动框架和用图表库构建可视化图表究竟有什么区别。
## 课前准备
与上一节课差不多我们还是需要使用SpriteJS只不过今天我们将QCharts换成D3.js。
```
&lt;script src=&quot;https://unpkg.com/spritejs/dist/spritejs.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;https://d3js.org/d3.v6.js&quot;&gt;&lt;/script&gt;
```
使用上面的代码我们就能加载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) =&gt; {
return 200 + i * 95;
})
.attr('width', scale)
.attr('height', 80)
.attr('bgcolor', (d, i) =&gt; {
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坐标值设置为450y坐标值设置为从200开始每个元素占据95个像素值然后将width设置为用scale计算后的数据项的值这里我们就用到前面linearScale高阶函数生成的scale函数直接将它作为算子。我们将height值设为固定的80表示元素的高度。这样一来元素之间就会有 95 - 80即15像素的空隙。最后我们给元素设置一组不同的颜色值。
我们最终显示出来的效果如下图:
<img src="https://static001.geekbang.org/resource/image/8e/c6/8e24ca6f4bfb4byyd71554yydc431bc6.jpg" alt="">
这里我们在画布上显示了五个不同颜色的矩形条,它们对应数组的 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上这样就能将坐标轴显示出来了。
<img src="https://static001.geekbang.org/resource/image/3f/dc/3fbbca34bd9bd53df1d79c64b0ecd3dc.jpg" alt="">
这样我们就实现了一个简陋的条形图。简陋是因为和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绘制更加复杂的图形比如力导向图。
力导向图也是一种比较常见的可视化图表,它非常适合用来描述关系型信息。比如下图就是一个经典的力导向图应用。
<img src="https://static001.geekbang.org/resource/image/d1/45/d10baf06984e9b356a13e3bd2dc92845.gif" alt="">
我们看到,力导向图不仅能够描绘节点和关系链,而且在移动一个节点的时候,图表各个节点的位置会跟随移动,避免节点相互重叠。
那么究竟如何用D3.js实现一个简单的力导向图呢我们来看一个例子。
力导向图顾名思义我们通过模拟节点之间的斥力来保证节点不会相互重叠。在D3.js中提供了模拟斥力的方法。
```
const simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d =&gt; d.id)) //节点连线
.force('charge', d3.forceManyBody()) // 多实体作用
.force('center', d3.forceCenter(400, 300)); // 力中心
```
如上面代码所示我们创建一个d3的力模型对象simulation通过它来模拟示例然后我们设置节点连接、多实体相互作用、力中心点。
接着,我们读取数据。这里我准备了一份[JSON数据](https://s0.ssl.qhres.com/static/f74a79ccf53d8147.json)。我们可以用d3.json来读取数据它返回一个Promise对象。
```
d3.json('https://s0.ssl.qhres.com/static/f74a79ccf53d8147.json').then(graph =&gt; {
...
});
```
我们先用力模型来处理数据:
```
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) =&gt; {
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) =&gt; {
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) =&gt; {
return `path${index}`;
})
.attr('strokeColor', 'white');
```
这里我们依然是用d3-selection的迭代给SpriteJS的sprite和path元素设置了一些属性这些属性有的与我们的数据建立关联有的是单纯的样式。这里面没有特别难的地方我就不一一解释了最好的理解方法是实践所以我建议你亲自研究一下示例代码修改一些属性看看结果有什么变化这样能够加深理解。
将节点和连线绘制完成之后,力导向图的初步结果就呈现出来了。
<img src="https://static001.geekbang.org/resource/image/a1/cb/a17d7c08d84fd101a8d2a991cb61e4cb.gif" alt="">
因为力向导图有一个特点就是在我们移动一个节点的时候其他节点也会跟着移动。所以我们还要实现拖动节点的功能。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会自动根据新的节点位置计算布局避免节点的重叠。
<img src="https://static001.geekbang.org/resource/image/01/f1/016ea964bba39df48d0894e39c45e0f1.gif" alt="">
## 要点总结
这节课,我们主要学习了使用数据驱动框架来绘制图表。
与直接使用图表库不同,使用数据驱动框架不要求固定格式的数据格式,而是通过对原始数据的处理和对容器迭代、创建新的子元素,并且根据数据设置属性,来完成从数据到元素结构和属性的映射,然后再用渲染引擎将它最终渲染出来。
那你可能有疑问了我们应该在什么时候选择图表库什么时候选择数据驱动框架呢通常情况下当需求比较明确可以用图表库并且样式通过图表库API设置可以实现的时候我们倾向于使用图表库但是当需求比较复杂或者样式要求灵活多变的时候我们可以考虑使用数据驱动框架。
数据驱动框架可以灵活实现各种复杂的图表效果我们前面举的两个图表例子虽然只是个例但也会在实战项目中经常用到。除此之外使用D3.js和SpriteJS还可以实现其他复杂的图表比如说地图或者一些3D图表以及我们在前面的课程中实现的3Dgithub代码贡献图就是使用D3.js和SpriteJS来实现的。
D3.js和SpriteJS的使用都比较复杂你是不可能用一节课系统掌握的我们只有继续深入学习并动手实践、积累经验才能在可视化项目中得心应手地使用它们来实现各种各样的可视化需求。
## 小试牛刀
最后我给你出了两个实践题。希望你能结合D3.js和SpriteJS的官方文档花点时间仔细阅读和学习再通过动手实践和反复练习最终掌握它们。
1. 请你完善我们课程中讲到的条形图给它实现y轴、图例和提示信息。
1. 你可以将上一节课用QCharts图表库实现的图表改用D3.js实现吗动手试一试体会一下它们使用方式和思路上的不同。
关于可视化图表的实战课程就讲到这里了,如果你对于图表绘制,还有什么疑问和困惑,欢迎你在留言区告诉我。我们下节课再见!
## 源码
课程中完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/d3-spritejs)
## 推荐阅读
[D3.js的官方文档](https://d3js.org/)
[SpriteJS的官方文档](https://spritejs.org/#/)

View File

@@ -0,0 +1,353 @@
<audio id="audio" title="39 | 实战(三):如何实现地理信息的可视化?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/7d/6976f390987yy974bc1a0697a2a19a7d.mp3"></audio>
你好,我是月影。
前段时间,我们经常能看到新冠肺炎的疫情地图。这些疫情地图非常直观地呈现了世界上不同国家和地区,一段时间内的新冠肺炎疫情进展,能够帮助我们做好应对疫情的决策。实际上,这些疫情地图都属于地理位置信息可视化,而这类信息可视化的主要呈现方式就是地图。
在如今的互联网领域地理信息可视化应用非常广泛。除了疫情地图我们平时使用外卖订餐、春运交通、滴滴打车这些App中都有地理信息可视化的实现。
那地理信息可视化该如何实现呢?今天,我们就通过一个疫情地图的实现,来讲一讲地理信息可视化该怎么实现。
假设我们要使用世界地图的可视化来呈现不同国家和地区从2020年1月22日到3月19日这些天的新冠肺炎疫情进展。我们具体该怎么做呢主要有四个步骤分别是准备数据、绘制地图、整合数据和更新绘制方法。下面我们一一来看。
## 步骤一:准备数据
新冠肺炎的官方数据在WHO网站上每天都会更新我们可以直接找到2020年1月22日到3月19日的数据将这些数据收集和整理成一份JSON文件。这份JSON文件的内容比较大我把它放在Github上了你可以去[Github仓库](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/covid-data.json)查看这份数据。
有了JSON数据之后我们就可以将这个数据和世界地图上的国家一一对应。那接下来的任务就是准备世界地图想要绘制一份世界地图我们也需要有世界地图的地理数据这也是一份JSON文件。
地理数据通常可以从开源社区中获取公开数据,或者从相应国家的测绘部门获取当地的公开数据。这次用到的世界地图的数据,我们是通过开源社区获得的。
一般来说地图的JSON文件有两种数据格式一种是GeoJSON另一种是TopoJSON。其中GeoJSON是基础格式它包含了描述地图地理信息的坐标数据。举个简单的例子
```
{
&quot;type&quot;:&quot;FeatureCollection&quot;,
&quot;features&quot;: [
{
&quot;type&quot;:&quot;Feature&quot;,
&quot;geometry&quot;:{
&quot;type&quot;:&quot;Polygon&quot;,
&quot;coordinates&quot;:
[
[[117.42218831167838,31.68971206252246],
[118.8025942451759,31.685801564127132],
[118.79961418869482,30.633841626314336],
[117.41920825519742,30.637752124709664],
[117.42218831167838,31.68971206252246]]
]
},
&quot;properties&quot;:{&quot;Id&quot;:0}
}
]
}
```
上面的代码就是一个合法的GeoJSON数据它定义了一个地图上的多边形区域坐标是由四个包含了经纬度的点组成的代码中一共是五个点但是首尾两个点是重合的
那什么是TopoJSON格式呢TopoJSON格式就是GeoJSON格式经过压缩之后得到的它通过对坐标建立索引来减少冗余数据压缩能够大大减少JSON文件的体积。
因为这节课的重点主要是地理信息的可视化绘制而GeoJSON和TopJSON文件格式的具体规范又比较复杂不是我们课程的重点所以我就不详细来讲了。如果你有兴趣进一步学习可以参考我在课后给出的资料。
这节课我们直接使用我准备好的两份世界地图的JSON数据就可以了一份是[GeoJSON数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/world-geojson.json),一份是[TopoJSON数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/world-topojson.json)。接下来,我们会分别来讲怎么使用它们来绘制地图。
## 步骤二:绘制地图
将数据绘制成地图的方法有很多种我们既可以用Canvas2D、也可以用SVG还可以用WebGL。除了用WebGL相对复杂用Canvas2D和SVG都比较简单。为了方便你理解我选择用比较简单的Canvas2D来绘制地图。
首先我们将GeoJSON数据中coordinates属性里的经纬度信息转换成画布坐标这个转换被称为地图投影。实际上地图有很多种投影方式但最简单的方式是**墨卡托投影**也叫做等圆柱投影。它的实现思路就是把地球从南北两极往外扩先变成一个圆柱体再将世界地图看作是贴在圆柱侧面的曲面经纬度作为x、y坐标。最后我们再把圆柱侧面展开经纬度自然就被投影到平面上了。
[<img src="https://static001.geekbang.org/resource/image/cc/f4/cc45e95168fbfaf5bb76df694c13e3f4.jpg" alt="" title="墨卡托投影">](https://zh.wikipedia.org/wiki/%E9%BA%A5%E5%8D%A1%E6%89%98%E6%8A%95%E5%BD%B1%E6%B3%95)
墨卡托投影是最常用的投影方式,因为它的坐标转换非常简单,而且经过墨卡托投影之后的地图中,国家和地区的形状与真实的形状仍然保持一致。但它也有缺点,由于是从两极往外扩,因此高纬度国家的面积看起来比实际的面积要大,并且维度越高面积偏离的幅度越大。
在地图投影之前,我们先来明确一下经纬度的基本概念。经度的范围是-180度到180度负数代表西经正数代表东经。纬度的范围是-90度到90度负数代表南纬正数代表北纬。
接下来我们就可以将经纬度按照墨卡托投影的方式转换为画布上的x、y坐标。对应的经纬度投影如下图所示。
<img src="https://static001.geekbang.org/resource/image/b0/c4/b06a496725cdff471bf531ab3721ddc4.jpg" alt="">
注意精度范围是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}) =&gt; {
...遍历数据
...进行投影转换
...进行绘制
});
}();
```
在forEach迭代的时候我们可以拿到features数组中每一个元素里的geometry字段这个字段中包含有coordinates数组coordinates数组中的值就是经纬度值我们可以对这些值进行投影转换最后调用drawPoints将这个数据画出来。绘制过程十分简单你直接看下面的代码就可以理解。
```
function drawPoints(ctx, points) {
ctx.beginPath();
ctx.moveTo(...points[0]);
for(let i = 1; i &lt; points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.fill();
}
```
完整的代码我放在了[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/convid-vis/mercator.html)中,你可以下载到本地运行。这里,我直接把运行的结果展示给你看。
<img src="https://static001.geekbang.org/resource/image/37/c7/373977623609e83d8911f679240d7dc7.jpg" alt="">
以上就是利用GeoJSON数据绘制地图的全过程。这个过程非常简单我们只需要将coordinate数据进行投影然后根据投影的坐标把轮廓绘制出来就可以了。但是GeoJSON数据通常比较大如果我们直接在Web应用中使用有些浪费带宽也可能会导致网络加载延迟所以使用TopoJSON数据是一个更好的选择。
举个例子同样的世界地图数据GeoJSON格式数据有251KB而经过了压缩的TopoJSON数据只有84KB体积约为原来的1/3。
尽管体积比GeoJSON数据小了不少但是TopoJSON数据经过了复杂的压缩之后我们在使用的时候还需要对它解压把它变成GeoJSON数据。可是如果我们自己写代码去解压实现起来比较复杂。好在我们可以采用现成的工具对它进行解压。这里我们可以使用GitHub上的[TopoJSON官方仓库](https://github.com/topojson/topojson)的JavaScript模块来处理TopoJSON数据。
这个转换简单到只用一行代码就可以完成,转换完成之后,我们就可以用同样的方法将世界地图绘制出来了。具体的转换过程我就不多说了,你可以自己试一试。转换代码如下:
```
const countries = topojson.feature(worldData, worldData.objects.countries);
```
## 步骤三:整合数据
有了世界地图之后下一步就是将疫情的JSON数据整合进地图数据里面。
在GeoJSON或者TopoJSON解压后的countries数据中除了用geometries字段保存地图的地区信息外还用properties字段来保存了其他的属性。在我们这一份地图数据中properties只有一个name属性对应着不同国家的名字。
我们打开[TopoJSON文件](https://raw.githubusercontent.com/akira-cn/graphics/master/convid-vis/assets/data/world-topojson.json)就可以看到在contries.geometries下的properties属性中有一个name属性对应国家的名字。
<img src="https://static001.geekbang.org/resource/image/33/b6/331fb9c48b9c6245446190a9f19078b6.jpeg" alt="">
这个时候,我们再打开[疫情的JSON数据](https://raw.githubusercontent.com/akira-cn/graphics/master/convid-vis/assets/data/convid-data.json)我们会发现疫情数据中的contry属性和GeoJSON数据里面的国家名称是一一对应的。
<img src="https://static001.geekbang.org/resource/image/a8/70/a8975be77a6e8f3bdde2450c198e3f70.jpeg" alt="">
这样我们就可以建立一个数据映射关系将疫情数据中的每个国家的疫情数据直接写入到GeoJSON数据的properties字段里面。
接着,我们增加一个数据映射函数:
```
function mapDataToCountries(geoData, convidData) {
const convidDataMap = {};
convidData.dailyReports.forEach((d) =&gt; {
const date = d.updatedDate;
const countries = d.countries;
countries.forEach((country) =&gt; {
const name = country.country;
convidDataMap[name] = convidDataMap[name] || {};
convidDataMap[name][date] = country;
});
});
geoData.features.forEach((d) =&gt; {
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);
```
因为整合好的数据比较多,所以我只在这里列出一个国家的示例数据:
```
{
&quot;objects&quot;: {
&quot;countries&quot;: {
&quot;type&quot;: &quot;GeometryCollection&quot;,
&quot;geometries&quot;: [{
&quot;arcs&quot;: [
[0, 1, 2, 3, 4, 5]
],
&quot;type&quot;: &quot;Polygon&quot;,
&quot;properties&quot;: {
&quot;name&quot;: &quot;Afghanistan&quot;,
&quot;convid&quot;: {
&quot;2020-01-22&quot;: {
&quot;confirmed&quot;: 1,
&quot;recovered&quot;: 0,
&quot;death&quot;: 0,
},
&quot;2020-01-23&quot;: {
...
},
...
}
}
},
...
```
## 步骤四:将数据与地图结合
将全部数据整合到地理数据之后,我们就可以将数据与地图结合了。在这里,我们设计用不同的颜色来表示疫情的严重程度,填充地图,确诊人数越多的区域颜色越红。要实现这个效果,我们先要创建一个确诊人数和颜色的映射函数。
我把无人感染到感染人数超过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 &lt; 10) {
return 'rgb(250, 247, 171)';
}
if(confirmed &lt; 100) {
return 'rgb(255, 186, 66)';
}
if(confirmed &lt; 500) {
return 'rgb(234, 110, 41)';
}
if(confirmed &lt; 1000) {
return 'rgb(224, 81, 57)';
}
if(confirmed &lt; 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}) =&gt; {
... 读取当前日期下的确诊人数
ctx.fillStyle = mapColor(confirmed); // 映射成地图颜色并设置到Canvas上下文
... 执行绘制
});
```
我们先把data参数设为2020-01-22这样一来我们就绘制出了2020年1月22日的疫情地图。
<img src="https://static001.geekbang.org/resource/image/d7/65/d79fe31cc6b92340077ccb5e1f085865.jpg" alt="" title="2020年1月22日疫情地图">
可是疫情的数据每天都会更新如果想让疫情地图随着日期自动更新我们该怎么做呢我们可以给地图绘制过程加上一个定时器这样我们就能得到一个动态的疫情地图了它会自动显示从1月22日到当前日期疫情变化。这样我们就能看到疫情随时间的变化了。
```
const startDate = new Date('2020/01/22');
let i = 0;
const timer = setInterval(() =&gt; {
const date = new Date(startDate.getTime() + 86400000 * (++i));
drawMap(ctx, countries, date);
if(date.getTime() + 86400000 &gt; Date.now()) {
clearInterval(timer);
}
}, 100);
drawMap(ctx, countries, startDate);
```
<img src="https://static001.geekbang.org/resource/image/68/92/68676fbffedcac02dfa178d603025292.gif" alt="">
## 要点总结
这节课,我们讲了实现地理信息可视化的通用步骤,一共可以分为四步,我们一起来回顾一下。
第一步是准备数据包括地图数据和要可视化的数据。地图数据有GeoJSON和TopoJSON两个规范。相比较而言TopoJSON数据格式经过了压缩体积会更小比较适合Web应用。
第二步是绘制地图。要绘制地图,我们需要将地理信息中的坐标信息投影到地图上,最简单的投影方式是使用墨卡托投影。
第三步是整合数据,我们要把可视化数据和地图的地理数据集成到一起,这一步我们可以通过定义数据映射函数来实现。
最后一步,就是将数据与地图结合,根据整合后的数据结合地图完成最终的图形绘制。
总的来说无论我们要实现多么复杂的地理信息可视化地图核心的4个步骤是不会变的只不过其中的每一步我都可以替换具体的实现方式比如我们可以使用其他的投影方式来代替墨卡托投影来绘制不同形状的地图。
## 课后练习
<li>
我们今天选择使用Canvas来绘制地图是因为它使用起来十分方便。其实使用SVG绘制地图也很方便你能试着改用SVG来实现今天的疫情地图吗这和使用Canvas有什么共同点和不同点
</li>
<li>
我们今天使用的墨卡托投影是最简单的投影方法它的缺点是让高纬度下的国家看起来比实际的要大很多。你能试着使用D3.js的[d3-geo](https://github.com/d3/d3-geo)模块中提供的其他投影方式来实现地图吗?
</li>
3.如果 我们要增加交互,让鼠标移动到某个国家区域的时候,这个区域高亮,并且显示国家名称、疫情确诊数、治愈数以及死亡数,这该怎么处理呢?你可以尝试增加这样的交互功能,来完善我们的地图应用吗?
好啦,今天的地理信息可视化实战就到这里了。欢迎你把实现的地图作品放在留言区,也欢迎把这节课转发出去,我们下节课见!
## 源码
[1] [新冠肺炎数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/convid-data.json)<br>
[2] [GeoJSON数据](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/world-geojson.json)<br>
[3] [TopoJSON数据](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/world-topojson.json)<br>
[4] 完整的示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/covid-vis/mercator.html)
## 推荐阅读
[1] [GeoJSON标准格式学习](https://www.jianshu.com/p/852d7ad081b3)<br>
[2] [GeoJSON和TopoJSON][reference_end](https://blog.xcatliu.com/2015/04/24/geojson_and_topojson/)<br>
[3] [GeoJSON规范](https://geojson.org/geojson-spec.html)<br>
[4] [TopoJSON规范](https://github.com/topojson/topojson-specification/blob/master/README.md)

View File

@@ -0,0 +1,403 @@
<audio id="audio" title="40| 实战如何实现3D地球可视化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/28/ea5067ed98990893ee174644d7321828.mp3"></audio>
你好,我是月影。
前几节课我们一起进行了简单图表和二维地图的实战这节课我们来实现更炫酷的3D地球可视化。
3D地球可视化主要是以3D的方式呈现整个地球的模型视觉上看起来更炫酷。它是可视化应用里常见的一种形式通常用来实现全球地理信息相关的可视化应用例如全球黑客攻防示意图、全球航班信息示意图以及全球贸易活动示意图等等。
因为内容比较多所以我会用两节课来讲解3D地球的实现效果。而且由于我们的关注点在效果因此为了简化实现过程和把重点聚焦在效果上我就不刻意准备数据了我们用一些随机数据来实现。不过即使我们要实现的是包含真实数据的3D可视化应用项目前面学过的数据处理方法仍然是适用的。这里我就不多说了。
在学习之前你可以先看一下我们最终要实现的3D地球可视化效果先有一个直观的印象。
<img src="https://static001.geekbang.org/resource/image/2d/e5/2d3a38yy1b1a0974a830a277150a54e5.gif" alt="">
如上面动画图像所示我们要做的3D可视化效果是一个悬浮于宇宙空间中的地球它的背后是一些星空背景和浅色的光晕并且地球在不停旋转的同时会有一些不同的地点出现飞线动画。
接下来,我们就来一步步实现这样的效果。
## 如何实现一个3D地球
第一步我们自然是要实现一个旋转的地球。通过前面课程的学习我们知道直接用SpriteJS的3D扩展就可以方便地绘制3D图形。这里我们再系统地说一下实现的方法。
### 1. 绘制一个3D球体
首先我们加载SpriteJS和3D扩展最简单的方式还是直接使用CDN上打包好的文件代码如下
```
&lt;script src=&quot;http://unpkg.com/spritejs/dist/spritejs.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js&quot;&gt;&lt;/script&gt;
```
加载完成之后我们创建场景对象添加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上这样我们就能在画布上将这个球体显示出来了效果如下所示。
<img src="https://static001.geekbang.org/resource/image/11/21/11f33d265ded0f54973860671f265d21.jpeg" alt="">
现在,我们只在画布上显示了一个灰色的球体,它和我们要实现的地球还相差甚远。别着急,我们一步一步来。
### 2. 绘制地图
上节课,我们已经讲了绘制平面地图的方法,就是把表示地图的 JSON 数据利用墨卡托投影到平面上。接下来我们也要先绘制一张平面地图然后把它以纹理的方式添加到我们创建的3D球体上。
不过,与平面地图采用墨卡托投影不同,作为纹理的球面地图需要采用**等角方位投影**(Equirectangular Projection)。d3-geo模块中同样支持这种投影方式我们可以直接加载d3-geo模块然后使用对应的代码来创建投影。
从CDN加载d3-geo模块需要加载以下两个JS文件
```
&lt;script src=&quot;https://d3js.org/d3-array.v2.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;https://d3js.org/d3-geo.v2.min.js&quot;&gt;&lt;/script&gt;
```
然后,我们创建对应的投影:
```
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 = color.a;
}
`;
```
我们用上面的Shader来创建Program。这组Shader并不复杂原理我们在视觉篇都已经解释过了。如果你觉得理解起来依然有困难可以复习一下视觉篇的内容。接着我们创建一个Texture对象将它赋给Program对象代码如下。
```
const texture = layer.createTexture({});
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
```
现在,画布上就显示出了一个中心有些亮光的球体。
<img src="https://static001.geekbang.org/resource/image/ae/0d/aeb4a6736810d9d5674b48b1e683800d.jpeg" alt="">
从中我们还是看不出地球的样子。这是因为我们给的texture对象是一个空的纹理对象。接下来我们只要执行loadMap方法将地图加载出来再添加给这个空的纹理对象然后刷新画布就可以了。对应代码如下
```
loadMap().then((map) =&gt; {
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
```
最终,我们就显示出了地球的样子。
<img src="https://static001.geekbang.org/resource/image/b6/a3/b637c0ae60390d16ed72a17749a8d9a3.jpeg" alt="">
我们还可以给地球添加轨迹球控制并让它自动旋转。在SpriteJS中非常简单只需要一行代码即可完成。
```
layer.setOrbit({autoRotate: true}); // 开启旋转控制
```
<img src="https://static001.geekbang.org/resource/image/06/47/063675af883cf21766ba63af91f74347.gif" alt="">
这样我们就得到一个自动旋转的地球效果了。
## 如何实现星空背景
不过,这个孤零零的地球悬浮在黑色背景的空间里,看起来不是很吸引人,所以我们可以给地球添加一些背景,比如星空,让它真正悬浮在群星闪耀的太空中。
要实现星空的效果第一步是要创建一个天空包围盒。天空包围盒也是一个球体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节课中也有过类似的做法当时我们是用它来模拟水滴滚过的效果。
<img src="https://static001.geekbang.org/resource/image/72/91/72fbc484227f00ec4dcf7f2729c8f391.jpeg" alt="">
但在这里我们通过step函数和vUv的缩放将它缩小之后最终呈现出来星空效果。
<img src="https://static001.geekbang.org/resource/image/e2/c5/e2dfac7b34718ecc347969f807a6f9c5.jpeg" alt="">
对应的创建天空盒子的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仓库](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)中最好的理解方式还是你自己试着手动修改一下skyFragment中的绘制参数看看实现出来效果你就能明白了。
## 要点总结
这节课我们讲了实现3D地球可视化效果的方法以及给3D地球添加天空背景的方法。
要实现3D地球效果我们可以使用SpriteJS和它的3D扩展库。首先我们绘制一个3D球体。然后我们用topoJSON数据绘制地图注意地图的投影方式必须选择等角方位投影。最后我们把地图作为纹理添加到3D球体上这样就绘制出了3D地球。
而要实现星空背景我们需要创建一个天空盒子它可以看成是一个放大很多倍的球体包裹在地球的外面。具体的思路就是我们创建一组特殊的Shader通过二维噪声来实现星空的效果。
说的这里你可能会有一些疑问我们为什么要用topoJSON数据来绘制地图而不采用现成的等角方位投影的平面地图图片直接用它来作为纹理那样不是能够更快绘制出3D地球吗的确这样确实也能够更简单地绘制出3D地球但这么做也有代价就是我们没有地图数据就不能进一步实现交互效果了比如说点击某个地理区域实现当前国家地区的高亮效果了。
那在下节课我们就会进一步讲解怎么在3D地球上添加交互效果以及根据地理位置来放置各种记号。你的疑问也都会一一解开。
## 小试牛刀
我们说如果不考虑交互可以直接使用更简单的等角方位投影地图作为纹理来直接绘制3D地球。你能试着在网上搜索类似的纹理图片来实现3D地球效果吗
另外你可以找类似的其他行星的图片比如火星、木星图片来实现3D火星、木星的效果吗
最后,你也可以想想,除了星空背景,如果我们还想在地球外部实现一层淡绿色的光晕,又该怎么做呢(提示:你可以使用距离场和颜色插值来实现)?
<img src="https://static001.geekbang.org/resource/image/bc/39/bcc477ea54486f586e2b90715c944439.gif" alt="">
今天的3D地球可视化实战就到这里了。欢迎把你实现的效果分享到留言区我们一起交流。也欢迎把这节课转发出去我们下节课见
## 源码
[课程完整示例代码详](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)

View File

@@ -0,0 +1,600 @@
<audio id="audio" title="41 | 实战如何实现3D地球可视化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/c2/4c0b3a1be4caa1b8d1a6577920e295c2.mp3"></audio>
你好,我是月影。
上节课我们实现了一个有着星空背景的3D地球效果。但这个效果还比较简单在某些可视化大屏项目中我们不仅要呈现视觉效果还要允许用户与可视化大屏中呈现的内容进行交互。所以这节课我们会先给这个3D地球添加各种交互细节比如让地球上的国家随着我们鼠标的移动而高亮接着我们再在地球上放置各种记号比如光柱、地标等等。
## 如何选中地球上的地理位置?
我们先来解决上节课留下的一个问题为什么我们在绘制3D地球的时候要大费周章地使用topoJSON数据而不是直接用一个现成的等角方位投影的世界地图图片作为球体的纹理。这是因为我们想让地球能够和我们的鼠标进行交互比如当点击到地图上的中国区域的时候我们想让中国显示高亮这是纹理图片无法实现的。接下来我们就来看看怎么实现这样交互的效果。
### 实现坐标转换
实现交互效果的难点在于坐标转换。因为鼠标指向地球上的某个区域的时候我们通过SpriteJS拿到的是当前鼠标在点击的地球区域的一个三维坐标而这个坐标是不能直接判断点中的区域属于哪个国家的我们需要将它转换成二维的地图经纬度坐标才能通过地图数据来获取到当前经纬度下的国家或地区信息。
那如何实现这个坐标转换呢首先我们的鼠标在地球上移动的时候通过SpriteJS我们拿到三维的球面坐标代码如下
```
layer.setRaycast();
globe.addEventListener('mousemove', (e) =&gt; {
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课](https://time.geekbang.org/column/article/266346),自己来推导一下,或者阅读[这篇文章](https://zhuanlan.zhihu.com/p/34485962)。
拿到了二维平面直角坐标之后,我们可以直接用等角方位投影函数的反函数将这个平面直角坐标转换为经纬度,代码如下:
```
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) =&gt; {
const ret = d3.geoContains(d, [longitude, latitude]);
if(ret) idx = i;
return ret;
});
const info = idx &gt;= 0 ? {...countries.features[idx]} : {};
info.index = idx;
return info;
}
```
这样一来我们只要修改mousemove方法就可以知道我们的鼠标移动在哪个国家之上了。
```
globe.addEventListener('mousemove', (e) =&gt; {
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 &gt; 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) =&gt; {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, countries);
highlightMap(texture, country, countries);
});
```
<img src="https://static001.geekbang.org/resource/image/14/e8/14ea287ce617789ce37cd2e3d40ee4e8.gif" alt="">
## 如何在地球上放置标记?
通过选中对应的国家,我们可以实现鼠标的移动的高亮交互效果。接下来,我们来实现另一个交互效果,在地球的指定经纬度处放置一些标记。
### 如何计算几何体摆放位置?
既然要把物体放置在地球指定的经纬坐标处那我们接下来的操作依然离不开坐标转换。首先我们知道几何体通常默认是以中心点为0,0但我们放置的时候却需要将物体的底部放置在球面上所以我们需要对球面坐标位置进行一个坐标变换。
<img src="https://static001.geekbang.org/resource/image/82/06/82406267ec504e29b8d975789a4f8a06.jpg" alt="">
因此,我们在实现放置函数的时候,会通过 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 &amp;&amp; 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。这么做之后我们就可以在地球表面摆放几何体了。
### 摆放光柱
我们先在地球的指定位置上放置一些光柱,光柱通常可以用来标记当前位置是一个重要的地点。光柱效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/2d/67/2d02f5fd187b659b47a299baa4fe9e67.gif" alt="">
这个效果怎么实现呢因为光柱本身是圆柱体所以我们可以用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共同组成。摆放了地标的地图效果如下图所示
<img src="https://static001.geekbang.org/resource/image/bc/4f/bc82d420e44aed51be20a16f63c9434f.gif" alt="">
想要实现它第一步我们还是要实现对应的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 &lt;= 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 &lt;= 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) =&gt; {
return i % 4 === 2 ? 0.33 : v;
}));
vertex.push(...copied.map((v, i) =&gt; {
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(() =&gt; {
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仓库](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)
## 推荐阅读
[球坐标与直角坐标的转换](https://zhuanlan.zhihu.com/p/34485962)

View File

@@ -0,0 +1,111 @@
<audio id="audio" title="42 | 如何整理出我们自己的可视化工具集?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/9b/169c4556ea87100eb2ff21b128c8289b.mp3"></audio>
你好,我是月影。
我们知道可视化虽然可以认为是前端的一个小分支但总体来说它也是一个比较有广度的领域从内容上大体包括数据图表、图形绘制、地理信息可视化、3D场景和数字孪生应用等等从设备上包括移动端、PC浏览器和可视化大屏等等。
作为刚刚入门可视化领域的工程师,我们经常会因为知识面不够广、工具不够丰富,而寄望于用单一的工具来解决我们遇到的所有问题,所谓“手里有锤子,看什么都像是钉子”。想想也知道,这并不能很好地处理所有的可视化应用。因为,一旦我们选择了错误的、不适合于当前场景的工具,就会让最终实现的结果不理想,或者开发效率不高。
因此,想要成为一名优秀的可视化工程师,我们必须要知道如何丰富自己手中的工具集,来应对各种场景和挑战。丰富工具集,并不意味着我们需要从头开发一切适合各种场景使用的工具。大部分时候,我们可以利用行业发展和社区的力量,站在巨人的肩膀上,使用各种成熟的工具,来更好地完成我们的项目。
那今天,我带你一起系统地梳理目前行业里,适合可视化应用的优秀且成熟的工具,来丰富你的工具箱,让你在面对各种可视化需求时能够游刃有余。
首先我们来看看可视化应用的主体需求分类搞清楚了这些我们才能选择出更合适的工具来完成这些需求。根据可视化场景主体需求一般可以分成5种分别是绘制基本图形、绘制基础图表、绘制关系图和流程图、绘制地理信息以及绘制三维模型和数字孪生应用。接下来我就一一来说。
<img src="https://static001.geekbang.org/resource/image/88/a8/8869a44444710e25556fd1b801a400a8.jpeg" alt="">
## 选择绘制基本图形的工具
我们先来说说绘制基本图形要选择的工具。简单情况下我们用浏览器原生的四类基本图形系统就可以完成绘制分别是HTML/CSS、SVG、Canvas和WebGL。
HTML/CSS比较简单也有很多丰富的前端领域的工具这里就不多说了。SVG原生的API使用起来也并不复杂不过我们也可以用一些成熟的绘图库它能够让我们更方便地绘制各种图形。我比较推荐 [Snap.svg](http://snapsvg.io/) 这个库它提供的API能够非常方便地绘制各种SVG图形。尤其值得赞扬的是它提供了一套[交互式教学文档](http://snapsvg.io/start/),能够让你快速上手整个库的使用。
如果你使用Canvas和WebGL来绘制图形可选的工具就比较多了。比较成熟的2D渲染引擎包括 [Fabric.js](https://github.com/fabricjs/fabric.js)、[Pixi.js](https://github.com/pixijs/pixi.js) 等比较成熟的2D/3D引擎包括 [SpriteJS](https://spritejs.org/#/)、[P5.js](https://github.com/processing/p5.js) 等以及比较成熟的3D引擎包括 [ThreeJS](https://github.com/mrdoob/three.js)、[Babylon.js](https://github.com/BabylonJS/Babylon.js) 等。这些库各有特点,它们的官方使用文档和示例都比较完善,我建议你多花一些时间去学习它们的使用方法。这能帮助你在绘制基本图形的时候,灵活选用适合的库。
另外既然说到了Canvas2D绘制图形那我还要提一下[Rough.js](https://github.com/rough-stuff/rough)这个库。这是个非常有特点的库,它能够绘制出带有手绘风格的图形,比如,我们在[第5课](https://time.geekbang.org/column/article/255584)就使用过它。
我刚才说的这些工具对于绘制基本的2D/3D图形来说已经足够了。但是功能相对更强大和丰富的库体积也会更大一些使用上也会复杂一些所以我们还是应该根据实际需求复杂程度来灵活选择。
## 选择绘制基础图表的工具
绘制基础图表是可视化项目中很常见的需求,我们可以采用图表库和数据驱动框架来完成。常用的成熟图表库包括 [ECharts](https://echarts.apache.org/)、[Chart.js](https://github.com/chartjs/Chart.js)、[Highcharts](https://github.com/highcharts/highcharts)、[AntV G2](https://antv-2018.alipay.com/zh-cn/g2/3.x/index.html) 这4种。大部分图表库在使用上大同小异效果也差别不大好像选什么都没有差别。但在选择图表库的时候我们也需要考虑底层的图形系统或图形库这关系到复杂图表渲染和交互的性能另外在同时需要绘制图表和基本图形的时候选择统一的图形系统可以保持一致性也能更好地实现图表与图形的协同交互。
这里我就说几个常见的搭配组合。如果你使用SpriteJS作为底层图形库还可以选择[QCharts](https://www.qcharts.cn/#/home)。如果你是在移动端设备渲染图表,可以考虑使用[AntV F2](https://antv-2018.alipay.com/zh-cn/f2/3.x/index.html),这是一个专为移动端场景设计的图表库。如果你要绘制更加灵活的图表,以及关系图和流程图,可以选择数据驱动框架,例如[D3.js](https://github.com/d3/d3)。不过D3.js虽然很好用但它是一个足够复杂的框架如果你希望在可视化领域深入发展最好能再多花一些时间彻底掌握它的使用方法。
最后,我还想再说说[Vega](https://vega.github.io/vega/)这个库。这也是一个图表库它定义了一套基于JSON规范的可视化语法以声明式的方式来绘制各种图表。最关键的是Vega定义可视化语法规范的思路对我们自己设计和实现图表库有着非常大的借鉴意义。如果你打算自己设计一套图表库我希望你能好好研究一下Vega相信你会有所收获的。
## 选择绘制关系图和流程图的工具
接着,我们再来说一类特殊的图表,比如关系图、流程图、脑图等等。我们一般将它们单独归为一类应用,称为图可视化。
图可视化怎么实现呢?我们在[第38课](https://time.geekbang.org/column/article/291822)里使用D3.js实现过相关的例子。除了D3.js以外还有一类直接绘制这些图形的图可视化库常用的有[Mermaid.js](https://github.com/mermaid-js/mermaid)、[Sigma.js](http://sigmajs.org/)以及[AntV G6](https://antv-2018.alipay.com/zh-cn/g6/3.x/index.html)等等。
其中Mermaid.js量级更轻主要是以声明的方式来绘制各种流程图。而Sigma.js和AntV G6的功能更丰富实现的图可视化不仅类型更多还能包含复杂的用户交互效果。
此外,还有一个特殊的库[Dagre](https://github.com/dagrejs/dagre)。它是绘制流程图的底层库,主要是用来计算图的元素布局,使用它再结合图形库,我们就能自己实现一个绘制流程图的图可视化库。这个图可视化库实现起来也不是很难,我把这个任务留在本节课的末尾,你可以试着去挑战一下。
## 选择地理信息可视化工具
前面两节课我们一起完成了3D地理信息可视化的实战。地理信息可视化是可视化项目中很重要的一类应用大部分可视化大屏应用场景都会包含地图或者3D地球因为许多数据本就和地理信息相关。由于需求很多因此市场上有许多地理信息可视化的库可供我们选择。
比较成熟的地理信息可视化库包括[MapBox](https://www.mapbox.com/)、[MapTalks](https://maptalks.org/)、[Leaflet.js](https://leafletjs.com/)、[MapV](https://github.com/huiyan-fe/mapv)、[AntV L7](https://antv-2018.alipay.com/zh-cn/l7/1.x/index.html)等等它们都支持简单的geoJSON、topoJSON数据和分片加载的瓦片地图。另外d3的子模块[d3-geo](https://github.com/d3/d3-geo)也能够处理地理信息可视化,尤其是它提供了多种地图投影,非常适合与其他库联动,实现各种不同的地图场景应用。
总体而言地理信息可视化是可视化领域里比较复杂的方向这些地理信息可视化库的功能也较为丰富使用场景很多如果详细来讲的话内容足够支撑起一门单独的课程了。我们前面花了3节课来讲的地理信息可视化案例其实也只是给地理信息可视化应用开了一个头如果你有兴趣深入学习可以通过这些地理信息可视化库再结合实战来真正深入掌握地理信息可视化的方法这也是成为优秀可视化工程师的必经之路。
## 处理三维模型和数字孪生应用
在学习3D图形的绘制的时候我讲到3D绘制一般有两种方式一种是加载静态的3D模型数据然后将3D物体渲染出来。这些3D模型数据通常是通过设计工具离线生成的。这种应用场景在游戏领域比较常见。
[<img src="https://static001.geekbang.org/resource/image/1a/37/1af60e98b43a3b35a6e42e75d38e8937.jpeg" alt="" title="SpriteJS加载的3D模型数据图片来源spritejs.org">](https://spritejs.org/demo/#/3d/fox)
另一类则是动态加载3D几何模型用前面绘制基本图形的工具就可以实现。在可视化应用中这类场景通常更普遍。
[<img src="https://static001.geekbang.org/resource/image/be/f9/be76cd097e373b9c225dd361f58ee2f9.jpeg" alt="" title="动态加载的几何图形图片来源OGL">](https://oframe.github.io/ogl/examples/?src=scene-graph.html)
不过在可视化领域中有一类应用也会用到非常多的3D模型那就是数字孪生应用。所谓数字孪生是对物理世界实体或系统的数字化表达。简单来说就是在虚拟世界中通过3D渲染来还原真实物理世界这需要我们将现实世界中的物体模型化为虚拟世界中的3D几何体。
[<img src="https://static001.geekbang.org/resource/image/3b/fa/3bf6256eabca8c8d5a0bcd152154fdfa.jpeg" alt="" title="物理世界和数字孪生示意图图片来源36kr.com">](https://36kr.com/p/1723581366273)
在这样的应用场景中,有时候我们可以考虑采用游戏的方式,使用游戏引擎和框架,例如[Unity](https://store.unity.com/products/unity-pro?gclid=CjwKCAjwq_D7BRADEiwAVMDdHsaPnsc1S8jvT8yY47lLFn_jH6WvSTdhlDwf5RJtrC6Leu3LN--2HhoCUqIQAvD_BwE)或者[虚幻引擎](https://www.unrealengine.com/zh-CN/)来完成我们的可视化应用。当然,这也就进入了另一个领域,游戏创作的领域。这部分内容,我们简单了解一下就可以了。
## 要点总结
可视化有着丰富的使用场景大致上可以分为5类分别是绘制基本图形、绘制基础图表、图可视化、地理信息可视化以及数字孪生。
这些场景各自有适合的工具库和框架可供我们选择,我在这里整理了一个脑图,供你在具体应用中参考。
<img src="https://static001.geekbang.org/resource/image/4d/53/4d546e0ff09bfa513767ba71612c5e53.jpg" alt="">
这是我们专栏最后一个模块的最后一节课了在这几个月的时间里我们先后学习了图形学与数学、视觉基础、视觉高级以及数据处理的相关知识。其中我们通过一些可视化实战学习了部分图形系统与图形库和工具的使用这当中主要包括SpriteJS、D3.js、QCharts等等。这和我们这节课列出的各类工具相比可以说是非常少了。
我想有的同学可能会有疑问,还有这么多的工具我们并没有详细来讲,那我学习到这里,算是真正入门可视化了吗?我想说的是,工具和库都是为我们服务的,正确使用这些工具和库的基础,其实就是我们前面学过的图形学与数学基础、视觉呈现技术,以及数据处理的原则,这些内容才是可视化的本质。而上层的工具和库的使用虽然复杂,但并不难,基本上照着文档学习和实践,就能够一步步掌握。
而如果缺少扎实的基础,那么在使用工具遇到缺失功能或者性能问题的时候,你就可能会束手无策。因此,这门课,更多的还是为你打好基础,领你进入可视化的世界,今后的路,就要靠你自己一步一步地走了。
## 小试牛刀
今天我特意提到了Dagre这个库。它是一个基础库经常用来给流程图的元素布局不涉及渲染的部分。你能用它作为基础再以SpriteJS为渲染引擎来实现一个高效、强大的流程图库吗提示在API方面你可以借鉴Mermaid.js
除此之外这节课我们介绍的工具和库非常多你肯定很难在短时间内完全掌握但我也不希望你只是泛泛了解。所以我希望你能选择自己最感兴趣的同类库中的两个库利用我之前在GitHub仓库里留的北京天气和空气质量数据分别使用它们做一些可视化应用比较两个库在使用上的不同以及各自的优、劣势。
欢迎把你实现的效果分享到留言区,也欢迎你把自己在工具整理上的心得体会分享出来,我们一起交流!
## 推荐阅读
[1] [Snap.svg](http://snapsvg.io/)、[2] [Fabric.js](https://github.com/fabricjs/fabric.js)、[3] [Pixi.js](https://github.com/pixijs/pixi.js)
[4] [SpriteJS](https://spritejs.org/#/)、[5] [P5.js](https://github.com/processing/p5.js)、[6] [ThreeJS](https://github.com/mrdoob/three.js)
[7] [Babylon.js](https://github.com/BabylonJS/Babylon.js)、[8] [Rough.js](https://github.com/rough-stuff/rough)、[9] [ECharts](https://echarts.apache.org/)
[10] [Chart.js](https://github.com/chartjs/Chart.js)、[11] [Highcharts](https://github.com/highcharts/highcharts)、[12] [AntV G2](https://antv-2018.alipay.com/zh-cn/g2/3.x/index.html)
[13] [QCharts](https://www.qcharts.cn/#/home)、[14] [AntV F2](https://antv-2018.alipay.com/zh-cn/f2/3.x/index.html)、[15] [Vega](https://vega.github.io/vega/)
[16] [Mermaid.js](https://github.com/mermaid-js/mermaid)、[17] [Sigma.js](http://sigmajs.org/)、[18] [AntV G6](https://antv-2018.alipay.com/zh-cn/g6/3.x/index.html)
[19] [Dagre](https://github.com/dagrejs/dagre)、[20] [MapBox](https://www.mapbox.com/)、[21] [MapTalks](https://maptalks.org/)
[22] [Leafletjs](https://leafletjs.com/)、[23] [MapV](https://github.com/huiyan-fe/mapv)、[24] [AntV L7](https://antv-2018.alipay.com/zh-cn/l7/1.x/index.html)
[25] [d3-geo](https://github.com/d3/d3-geo)、[26] [Unity](https://store.unity.com/products/unity-pro?gclid=CjwKCAjwq_D7BRADEiwAVMDdHsaPnsc1S8jvT8yY47lLFn_jH6WvSTdhlDwf5RJtrC6Leu3LN--2HhoCUqIQAvD_BwE)、[27] [Unreal Engine](https://www.unrealengine.com/zh-CN/)

View File

@@ -0,0 +1,152 @@
<audio id="audio" title="加餐1 | 作为一名程序员,数学到底要多好?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/09/49632471f56176bc7f4851367c728709.mp3"></audio>
你好,我是月影。
刚刚学完了可视化的数学篇,今天咱们放松一下,以我的个人经历来聊一聊,数学对我们程序员的重要性。
作为奇舞团团长和从事前端15年以上的“老人”我为团队面试过许多同学也和许多同学聊过前端或者程序员的职业发展方向。一般来说我面试的时候会要求面试者有一定的数学基础在聊关于职业发展的话题时我也会强调数学对于程序员成长的重要性。甚至在可视化这门课里面我也认为学习可视化的第一步是学好图形学相关的数学知识。
不过行业里也有些不同的声音有些人觉得除了部分特殊的领域大部分普通的编程领域不太需要数学知识。比如说在我们平时的Web开发项目中不论是前端还是后端似乎更多地是和产品与业务逻辑打交道比较少或几乎没有用到数学知识。甚至有些人认为程序员根本用不着学好数学特别是在前端这样偏UI层的领域数学更是没有用武之地。
当然,以上这些认为数学不重要的想法,我都可以理解,曾经我自己也没有意识到数学和编程有什么必然的联系。而且,我当年在学校学习的时候,数学也学得很马虎,基础也不是那么好。不过后来,我个人的一段经历,让我很早就意识到数学对编程的重要性,而这个认知,对我后来的职业发展有着非常重要的影响。所以,我想在这里和你分享一些我个人成长中的经历和收获,希望能对你有些帮助。
## 实习面试的两个问题
2003年因为朋友的推荐我获得了微软亚洲研究院MSRA访问学生的面试机会当时的面试官是浙江大学的刘利刚博士他也是我后来的实习导师。那时他正在MSRA做访问学者。
在这之前,我没有任何面试经验,在学校里面,我的学习成绩也一般,只是对编程比较感兴趣,自己做过一些小项目。我不知道会被面试什么问题,所以也没特意准备。见到了利刚博士之后,他并没有问我任何有关编程的问题,而是问了我两个数学问题,这两个问题让我至今仍记忆犹新。
第一个问题是这样的:
>
已知ABC是三个不同的数字且能使以下等式成立求A、B、C分别是多少。
<img src="https://static001.geekbang.org/resource/image/f5/3f/f57c583ec3134c974fyy55a08125c23f.jpeg" alt="">
求这道题的答案并不是很难,但是花多久的时间能得出答案却是一个问题。我当时回答出这个问题,大概只用了**不到10秒钟**。你可以先试着解一下看看你能在10秒内给出这个问题的答案吗
其实,利刚博士出这道题,主要在考察我的**数感**。啥是数感呢?这不是指一个人具备了多么高深的数学知识,而是指他对数字的一种直觉以及洞察力。
我在解决这个问题的时候,完全是脱口而出答案,我甚至都没有意识到自己是怎么得出来的。但是,当我一下说出答案之后,再回想为什么才反应过来,这道题其实是有规律的。
<img src="https://static001.geekbang.org/resource/image/84/2f/8432ccabd92ec9yy30bf1c820121252f.jpeg" alt="">
你仔细看中间这一列应该能一下子得出A的值是9。然后再看第一列就能得到C的值是4最后B的值自然就是5了。所以答案就是A = 9、B = 5、C = 4。
那面试为啥要考察数感呢?利刚博士是这么给我解释的:数感好表示学习和理解能力强,因为访问学生要做的工作内容就是图形学的基础研究,一些知识肯定是要现学的,这需要有比较强的学习能力和理解力,所以作为数学基础的数感就很重要了。正是通过这个问题,我认识到了数感的重要性。
好了,接下来我们接着来看第二个问题。
>
给你一个天平和一个物体让你设计一些砝码无论这个物体的重量是在1100克之间的任何一个整数克数都能用这些砝码称量出来并且砝码的数量要尽可能少你最少需要几个砝码呢
乍一看,这个问题似乎与计算机和编程完全不沾边,但实际上这个问题涉及基础的**数的进制原理**。为什么说这个天平称重涉及数的进制原理呢?
因为我们知道天平一般来说标准的用法是左边托盘放物体右边托盘放砝码。那如果我们要称重的物体在1100克之间还要设计尽可能少的砝码最优解肯定是称某个克数的砝码组合是唯一的这样最省砝码。
怎么理解呢?我举个例子。
假如说现在我们有3个砝码分别为A砝码1克、B砝码2克、C砝码也是2克显然这3个砝码可以称15克的物体。如果物体是1克的话那么用A砝码就行如果物体是2克的话有两种方法用B砝码或者用C砝码如果物体是3克的话也有两种方法用A+B或A+C砝码如果物体是4克的话用B+C砝码如果物体是5克的话用A+B+C砝码。
但是我们看到在物体是2克和3克的时候分别有两种砝码组合对应的称量方法。如果我们把一种砝码组合作为一种编码再把一种物体克数作为一个状态的话那么重复的编码就只表示同一种状态这就属于浪费。显然更好的解决办法是用最少的编码组合表示尽可能多的状态。甚至我们应该做到一种编码唯一对应一种状态这样才是最优的。
所以呢我们应该把C砝码改为4克这样一来3个砝码就可以称出17克的物体而且没有任何两种编码表示同一种状态这就是最优的。我把具体的称量方法总结出一张表列在了下面。
<img src="https://static001.geekbang.org/resource/image/0b/b5/0b5fd71ddfafdf0ec00b9c59cc2930b5.jpeg" alt="">
看到这里聪明的同学应该已经知道这一题的答案了。实际上我们将砝码被使用记为1将砝码不被使用记为0那这个问题就等价于用多少位二进制数可以表示不大于100的正整数因此答案自然是7位也就是说砝码需要7个重量分别是1克、2克、4克、8克、16克、32克和64克。
所以你看,这个问题表面上是天平问题,实际上牵扯到数的进制表示,或者说是编码,这显然是一个计算机问题。这其实也是利刚博士问我这个问题的真正目的,它同时考查了我关于数学模型的抽象能力,以及对计算机基础知识的理解程度。
顺便再说一下,这个问题如果允许将砝码放在天平左侧托盘中,那么有一个技巧可以让用到的砝码数量更少,你能想到该怎么做吗?如果你想到了,可以在留言里分享你的答案。
## 我的图形学实习经历
回答出这两个问题之后,我通过了面试,来到微软亚洲研究院实习。我的课题是图形学基础研究,恰好是和三角剖分有关,具体来说是简单多边形的相容三角剖分。
什么是简单多边形的相容三角剖分呢?简单来说,就是将两个简单多边形剖分成同样数量的三角形,同时还需要保证每个三角形的顶点能够一一对应。所谓一一对应,就是给两个多边形的顶点进行编号之后,它们中每一对三角形顶点的编号都相同。
如果两个简单多边形的边数相同,在不允许添加内部点的情况下,并不总能构成相容三角剖分。比如下图中的两个六边形,左边三条虚线构成三角形,而右边的三条虚线却相交于一个点,所以对这两个图形来说,如果我们不添加内部点,就不存在相容三角剖分了。
[<img src="https://static001.geekbang.org/resource/image/3a/fb/3aceba1a5f298aee4a187209e8a10afb.jpg" alt="">](https://www.sciencedirect.com/science/article/pii/0925772193900285)
如果我们允许在图形内部添加点进行三角剖分,就可以得到相容三角剖分了,剖分后的效果如下图:
<img src="https://static001.geekbang.org/resource/image/b5/5a/b596d3964bd48084e9c00f5b1d93c65a.jpg" alt="">
而我当时的工作主要是研究如何对多边形进行快速相容三角剖分。之所以研究相容三角剖分,是因为通过相容三角剖分可以生成拓扑结构相同的三角网格,而拓扑结构相同的三角网格是实现物体变形特效的基础。比如说,我们可以用相容三角剖分实现人物的“变脸”特效等等。
[<img src="https://static001.geekbang.org/resource/image/9d/70/9da642f984cdc8c90a9845f34873cc70.jpg" alt="">](https://mappingignorance.org/2018/02/21/triangulations-face-morphing/)
大体上,我的研究就是围绕相容三角剖分涉及的算法,因为它们都比较复杂,这里我就不多说了。接下来,我主要介绍一下我的工作中遇到的问题。因为涉及图形学的基础内容,自然会有一些基础的图形学计算,比如计算点到直线和线段的距离,计算边的切线和法线,判断线段的关系,绘制圆锥曲线等等。
## 我实习的第一个任务
在我刚开始实习的时候,第一个任务就是要计算点到直线和线段的距离。
不过,一开始我在求点到直线的距离的时候,是先写出直线的两点式代数方程,然后求点与直线的垂线方程,接着将两个方程联立求交点,最后再求出点与交点的距离。
这么做当然是可以求出结果来的但是计算过程可以说是相当繁琐了而且它还有缺陷。缺陷究竟是什么呢我们知道用直线方程求垂线的时候要用到点斜式但是点斜式的斜率在直线垂直于X轴时会有斜率计算出来是无穷大的问题。这种特殊情况还需要特殊处理就更增加了我计算的复杂度。
所以,当时我花了一天时间才把“求点到直线和线段距离的问题”用代数方程解决。但是,第二天给利刚博士交差的时候,就被他批评了一顿,他问我为什么要用代数方程去做这个问题,如果用向量来做,根本就是分分钟的事儿。
如果你认真学了数学篇的课程,应该也已经知道,用向量解决这个问题的确非常简单。因为向量叉积的几何意义就是平行四边形的面积,在用向量叉积除以底边就是高,也就是点到向量所在直线的距离了。而我当时并没有想起可以用向量来解决这类问题,所以才走了弯路。
经过这次教训,我深刻意识到**选择正确数学工具,能够把看似非常复杂的问题转化为简简单单问题,从而顺利解决**。这也是为什么数学对于程序员来说非常重要。这个教训也是我在MSRA实习中最重要的收获。
## 两次数学实践
大约4个多月后我就结束了MSRA的实习回到了学校。毕业后我去了一家深圳的软件公司真正地成为了一名程序员。后来更是在机缘巧合下接触到了前端也一直成长到今天。
在成长的过程中,我始终牢记:用数学思想和意识去解决工作中的问题。你别说,还真被我遇到了两个事儿。接下来,我就和你说说我印象最深刻的这两个案例。
第一个案例是我在08年到百度时遇到的。当时某个产品中有一个绘制椭圆的需求负责开发的工程师是使用椭圆的代数方程来计算的。因为代数方程涉及开平方的问题所以开发人员还要根据象限来判断正负号这会非常麻烦。
<img src="https://static001.geekbang.org/resource/image/ea/54/eafdc7f9f5798c7041c2c5623b23d054.jpeg" alt="" title="椭圆代数方程涉及开根号">
现在你学习了数学篇的课程,应该知道用椭圆的参数方程来解决,根本不会涉及开根号和象限判断问题,操作起来也会简单很多。
<img src="https://static001.geekbang.org/resource/image/95/7a/95832de155b7bb82f9ed6682fc6de17a.jpeg" alt="" title="椭圆的参数方程">
另一个案例是我在360搜索的一个运营活动中遇到的。当时需要我们实现一个Canvas2D的图片特效效果如下
<img src="https://static001.geekbang.org/resource/image/96/60/965a77b94f7f9608145e3a0041a88f60.gif" alt="">
具体要求是在一张静态图片上,选择若干个运动区域,比如示意图中的猫的耳朵、眼睛、鼻子区域,然后对区域做这样逆时针旋转的运动,最终将它变为动图。
这其实是一个Canvas2d的特效。最初的时候我们的工程师是使用代数方法来解决的具体的方法如下
<img src="https://static001.geekbang.org/resource/image/e4/1e/e420d7c88ab0f83bab3b198ff2afa81e.jpg" alt="">
代数方法虽然能解决问题但是会有4个缺陷
1. 求角需要计算反正切,性能差;
1. 计算中需要开平方,要判断符号;
1. 求反正切有无穷大问题,需要特殊处理;
1. 有重复的计算量,进一步消耗性能。
不过,在我采用了向量法进行优化之后,这个动图的性能提升了数倍。具体算法如下图:
<img src="https://static001.geekbang.org/resource/image/3f/13/3f6aff92c3e441f6fe689726a9d02f13.jpg" alt="">
这两个案例其实共同说明一个道理:**学会选择合适的数学工具,才能用最优的方式轻松解决问题。**
## 小结
以上就是我和数学相关的个人经历了。总的来说,我收获到最重要的经验就是,要学会选择合适的数学工具来解决问题。当你选对工具之后,那些看似复杂的问题,可能会变得无比简单,从而被迅速解决。所以我们要重视数学基础的积累,锻炼自己的数感,拓宽知识面,以数学思维来思考计算机问题。
当然,我也不是说,程序员必须要有多么高深的数学知识。你看我们专栏中需要的数学知识,基本上就是一些高中数学知识和一部分基础的线性代数知识。但是数感、数学思维锻炼还是很重要的,这些锻炼越多,程序员的逻辑能力、抽象能力也会得到提高。更重要的是,通过锻炼我们能形成用数学思维思考问题的习惯,这样才能迅速找到最合适解决某类问题的数学工具,从而提升我们的技术能力。
## 小试牛刀
最后我再留两道思考题,来锻炼一下你的数感和数学思维能力。
1. 我们知道简单多边形和复杂多边形区别是,是否有非相邻的边相交。那为了判断一个多边形是不是简单多边形,我们可以实现一个函数,来判断两个线段是否相交。你能实现这个函数吗?
<img src="https://static001.geekbang.org/resource/image/90/4c/90d19b7614fd4a83c6eeb8224d4f8d4c.jpg" alt="" title="左侧ab、cd线段相交右侧ab、cd线段不相交">
1. 假如你手里有5个硬币已知随机抛一次之后有的硬币正面朝上有的硬币反面朝上。请问随机抛一次有3个或3个以上硬币正面朝上的概率是多少
欢迎你在留言说说数学对你的影响,也欢迎你和我分享关于数学方面的疑惑,我们一起探讨。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="加餐2 | SpriteJS我是如何设计一个可视化图形渲染引擎的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/f6/83eec3973a79e8c60fe7f667a119d1f6.mp3"></audio>
你好,我是月影。
今天我们来聊一个相对轻松的话题它不会有太多的代码也不会有什么必须要掌握的理论知识。不过这个话题对你理解可视化了解渲染引擎也是有帮助的。因为我今天要聊的话题是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](https://d3js.org/)来实现可视化图表的。
对D3.js来说[D3-selection](https://github.com/d3/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) =&gt; {
return `${200 + i * 45}px`;
})
.style('width', d =&gt; `${d}px`)
.style('height', '40px')
.style('background', (d, i) =&gt; colors[i]);
```
<img src="https://static001.geekbang.org/resource/image/07/37/07f31da9e193e658c7ed5c528733b437.jpeg" alt="">
这是一个非常快速且方便的绘图方式但它也有局限性。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) =&gt; {
return 200 + i * 45;
})
.attr('width', d =&gt; d)
.attr('height', 40)
.attr('bgcolor', (d, i) =&gt; 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](https://github.com/Automattic/node-canvas)运行在Node.js环境中所以我们就能够使用服务端渲染来实现一些特殊的可视化项目。比如我们曾经有一个项目要处理大量的历史数据大概有几十万到上百万条记录如果在前端分别绘制它们性能一定会有问题。所以我们将它们通过服务端绘制并缓存好之后以图像的方式发送给前端这样就大大提升了性能。此外我们还通过在适配层上提供不同的封装让SpriteJS 2.0支持了小程序环境,也能够运行在微信小程序中。
<img src="https://static001.geekbang.org/resource/image/d8/de/d89d6595133c63993a7cd178212ecfde.jpeg" alt="">
上图是SpriteJS 2.0的主体架构它的底层由一些通用模块组成Sprite-core是适配层SpriteJS是支持浏览器和Node.js的运行时Sprite-wxapp是小程序运行时Sprite-extend-*是一些外部扩展。我们通过外部扩展实现了粒子系统和物理引擎以及对主流响应式框架的支持让SpriteJS 2.0可以直接支持[vue](http://vue.spritejs.org/)和[react](http://react.spritejs.org/)。
<img src="https://static001.geekbang.org/resource/image/b7/bd/b7703aa427cfbc75576a17e092d1eebd.gif" alt="" title="SpriteJS 2.0通过扩展实现物理引擎">
除此以外SpriteJS 2.0还支持了文字排版和布局系统。其中文字排版支持了多行文本自动换行实现了几乎所有CSS3支持的文字排版属性布局系统则支持了完整的弹性布局Flex layout)。这两个特性被很多用户喜爱。
可以说我们对SpriteJS 2.0做了加法让它在1.0的基础上增加了许多强大且有用的特性。到了2019年底我又开始思考实现SpriteJS 3.0。这次我打算对特性做一些取舍将许多特性从SpriteJS 3.0中去掉,甚至包括深受使用者喜爱的文字排版和布局系统。这又是为什么呢?
这是因为SpriteJS 2.0虽好,但是它也有一些明显的缺点:
1. 只支持Canvas2D尽管有缓存策略性能仍然不足
1. 多平台适配采用不同的分支,维护起来比较麻烦;
1. 支持了许多非核心功能如文字排版、布局使得JavaScript文件太大
1. 不支持3D绘图。
## SpriteJS v3.x 2019年~2020年
在SpriteJS 3.0中我舍弃了非核心功能将SpriteJS定位为纯粹的图形渲染引擎 核心目标是追求极致的性能。
在适配层上SpriteJS 3.0完全舍弃了2.0设计里面较重的sprite-core采用了更轻量级的图形库[mesh.js](https://github.com/mesh-js/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的运行环境中去。
<img src="https://static001.geekbang.org/resource/image/bd/f5/bdba6a3a2466a882abeyybaeb7f7f6f5.jpeg" alt="" title="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阻塞从而进一步提升性能。
<img src="https://static001.geekbang.org/resource/image/8a/6b/8abb93673a58afe174349c310268f36b.gif" alt="" title="SpriteJS 3.0 绘制5万个地理信息点60fps帧率">
总之SpriteJS 3.0 随着性能的优化,已经成为一个纯粹的可视化渲染引擎了,但在我看来它仍然有些问题:
1. 性能优化得不够极致,数据压缩和批量渲染没有做到最好;
1. JS的矩阵运算还是不够快计算性能有提升空间
1. 因为考虑到兼容性的问题所以我采用了Canvas2D的降级这让JavaScript包仍然有些大
1. 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。
<img src="https://static001.geekbang.org/resource/image/50/42/508df6e988a9b1cbef17595f441b7642.jpg" alt="" title="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、贡献代码。
好了,今天的内容就到这里,我们下节课见!
## 推荐阅读
1. [D3.js](https://d3js.org)
1. [SpriteJS](https://spritejs.org)
1. [Mesh.js](https://github.com/mesh-js/mesh.js)

View File

@@ -0,0 +1,59 @@
<audio id="audio" title="加餐3 | 轻松一刻:我想和你聊聊前端的未来" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/47/045bfb932c7d13d12c871ea3c6f91647.mp3"></audio>
你好,我是月影。今天咱们来聊一个轻松点的话题。
我做前端工程师也有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它们是一些跨界交叉领域发展来的前沿的技术同样也值得我们持续关注。
## 给前端开发的一些真诚建议
最后,我想从前端工程师以及技术管理的角度,总结一些我自己的经验分享给你。
首先,你要确定自己是不是真正喜欢和热爱前端开发这个职业。当然我相信,大多数同学成为前端工程师,是因为内心真正喜欢这个职业。但是,之前我也听到有些同学说,因为觉得在程序员中前端比较“简单”,或者觉得自己数学或算法基础不好,做前端对这些要求不高,再或者就是觉得前端工程师算是份体面的职业,所以才选择它,其实内心并没不是真的热爱这个职业。
如果你仅仅把它当作一份谋生工作的话,那么你可能在这个职业道路上也走不了多远,肯定也无法达到很高的高度。所以我建议你反思一下,自己是否真的适合前端开发这个职业。
如果你确实热爱这个职业,正在考虑长远发展,我建议你最好选择一个好的平台,一个技术氛围好的团队,一份节奏合适的工作。我说的节奏合适指的是忙闲交替,既不会长时间特别忙,也不会持续特别闲。在这样的节奏下,项目积累再加上自己的学习沉淀,你就可以快速地成长了,而且技术氛围好的团队,也可以加快你学习沉淀的速度。
想在专业上达到一定的高度,因为每个人的情况不一样,所以我们要根据自己的情况来规划。不过总还是能找到一些共通点的,我觉得有一点很合适:找到并突破前端领域的“边界”。这个边界可以是某些有深度领域的技术前沿,也可以是某个交叉领域,如与服务端的交界,与移动客户端的交界等等。如果我们能在这些边界上做出突破,就肯定可以步入前端专家的行列了。
其实前端专家除了需要技术能力以外,还需要有意识地打造自己的个人影响力,锻炼自己的领导力,要让自己心态开放、眼界开阔,不排斥新技术,拥抱开源,多参与社区。
总之一句话,想要在职业之路上达一个比较高的高度,软实力和硬实力我们要两手抓。
今天说了这么多,其实希望如果你真的下定决心在前端这条路上钻研下去,一定别忘了,方向和努力缺一不可。最后,希望正在看这节课的你,可以成为未来优秀的前端专家,我们一起让前端行业变得更好。
今天的分享有没有解决你的疑惑呢?快把这节课分享给你的朋友吧!今天的内容就到这里了,我们下节课见!

View File

@@ -0,0 +1,272 @@
<audio id="audio" title="加餐4 | 一篇文章,带你快速理解函数式编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/28/dedb0fcdceff5526512e9996fb510228.mp3"></audio>
你好,我是月影。今天,我们来讨论函数式编程。
我看到很多同学留言说课程中给出的代码例子有的地方看不明白。我把同学们看不懂的地方汇总了一下发现大部分都是我使用函数式编程来写的。比如我在第7讲说过的 parametric 高阶函数第12讲说过的 traverse 的设计还有第15讲中使用的 toPolar/fromPolar 和改进版的 parametric 设计以及数据篇中的数据处理和D3.js的使用。
如果你还不习惯函数式编程思想,并且也觉得这些代码不容易理解,想知道为什么一定要这么设计,那这节课,你一定要好好听,我会和你系统地说说过程抽象和函数式编程这个话题。
## 两种编程范式:命令式与声明式
首先我先来说说什么是编程范式。编程范式有两种分别是命令式Imperative和声明式Declarative命令式强调做的步骤也就是怎么做而声明式强调做什么本身以及做的结果。因此编程语言也可以分成命令式和声明式两种类型如果再细分的话命令式又可以分成过程式和面向对象而声明式则可以分成逻辑式和函数式。下面这张图列出了编程语言的分类和每个类型下经典的编程语言。
<img src="https://static001.geekbang.org/resource/image/37/ae/3797e89ec55b48662c0e2ca58aa792ae.jpeg" alt="">
你注意看这张图里并没有JavaScript。实际上像JavaScript这样的现代脚本语言通常具有混合范式也就是说JavaScript同时拥有命令式和声明式的特征。因此开发者可以同时用JavaScript写出命令式与声明式风格的代码。举个例子我们要遍历一个数组将每一个元素的数值翻倍我们可以分别用命令式和声明式来实现。
首先,是命令式的实现代码:
```
let list = [1, 2, 3, 4];
let map1 = [];
for(let i = 0; i &lt; list.length; i++){
map1.push(list[i] * 2);
}
```
然后是声明式的实现代码:
```
let list = [1, 2, 3, 4];
const double = x =&gt; 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 =&gt; {
dosth...
done!
});
```
而非纯函数因为比较依赖外部环境,在测试的时候我们还需要构建外部环境。
```
//test with impure functions
//always need hooks
test.before(t =&gt; {
//setup environments
});
test.after('cleanup', t =&gt; {
//clean
});
test(t =&gt; {
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 =&gt; setColor(el, color));
}
```
这个模块中有两个方法其中setColor是操作一个DOM元素改变它的文字颜色而setColors则是批量操作若干个DOM元素改变所有元素的颜色。
尽管这两个方法都非常简单但它们都改变了外部环境DOM所以它们是两个非纯函数。因此我们在做系统测试的时候两个方法都需要构建外部环境来实现测试。
如果想让系统测试更简单我们是不是可以采用函数式编程思想把非纯函数的个数减少一个呢当然可以我们可以实现一个batch函数来优化。batch函数接受的参数是一个函数f就会返回一个新的函数。在这个过程中我们要遵循的调用规则是如果这个参数有length属性我们就以数组来遍历这个参数用每一个元素迭代f否则直接用当前调用参数来调用f就可以了。
具体的实现代码如下:
```
function batch(fn){
return function(target, ...args){
if(target.length &gt;= 0){
return Array.from(target).map(item =&gt; fn.apply(this, [item, ...args]));
}else{
return fn.apply(this, [target, ...args]);
}
}
}
```
因为batch函数的参数和返回值都是函数所以它有一个专属的名字**高阶函数(High Order Function)**。高阶函数虽然看上去复杂,但它实际上就是一个纯函数。它的执行结果只依赖于参数(传入的函数),与外部环境无关。
我们可以测试一下这个batch 函数的正确性方法十分简单只要用下面这个Case就行了。
```
test(t =&gt; {
let add = (x, y) =&gt; 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是一个高阶函数。所谓高阶函数是指输入参数是函数或者返回值是函数的函数。
<img src="https://static001.geekbang.org/resource/image/63/57/63d5941b67608e33cb71ebca2352c557.jpeg" alt="">
如果输入参数和返回值都是函数,这样的高阶函数又叫做**函数装饰器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 &lt;= 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函数封装的是一个过程这种封装过程的思路叫做**过程抽象。<strong>前面的函数装饰器还有batch方法实际上也是过程抽象。对应的一般程序设计中我们不是封装过程而是封装数据所以叫做**数据抽象</strong>
过程抽象是函数式编程的基础,函数式编程对待函数就像对待数据一样,都会进行封装和抽象,这样能够设计出非常通用的功能模块。
## 要点总结
函数式编程的内容非常多,这一节课,我只是借助了这些基础的概念和代码,把你带进了函数式编程的大门。
首先我们了解了两种不同的编程范式分别是命令式和声明式。其中函数式属于声明式而过程式和面向对象则属于命令式。JavaScript语言是同时具有命令式和声明式特征的编程语言。
然后,我们知道函数式有一个非常大的优点,就是能够减少非纯函数的数量,这也是我们设计系统时要遵循的原则。因为相比于非纯函数,纯函数具有更好的可测试性、执行效率和可维护性。
最后,我们还学会了使用高阶函数和函数装饰器来设计纯函数,实现通用的功能。这种思路是对过程封装,所以叫做过程抽象,它是函数式编程的基础。
## 小试牛刀
1. 如果你了解react你会发现react-hooks其实上就是纯函数设计。你可以思考一下如果引入了它能给你的系统带来什么好处
1. 我们在前端业务中也会用到一些常用的函数装饰器比如节流throttle和防抖debounce你能说说它们的使用场景吗如果让你实现这两个函数装饰器你又会怎么做呢
函数式编程的思想你都理解了吗?那不妨也把这节课分享给你的朋友吧。今天的内容就到这里了,我们下节课见!

View File

@@ -0,0 +1,82 @@
你好,我是月影。
专栏更新过半啦,我也收到了一些基础薄弱的用户反馈,说这门课难学。今天,我特意邀请了爱民老师,来和我们分享他的学习方法、心得。你可能会好奇,为什么今天没有音频。我希望你能静下心来,花上十几分钟,跟随着爱民老师一起思考学习的道理。
>
<p>昔闻人言:天下之事,最轻易者莫过于求学,最恒难者,莫过于问道。予深以为然。<br>
&nbsp;<br>
——引言</p>
你好,我是周爱民。今天出现在这个课堂上,并不是因为我对月影兄所讲的“可视化”有着怎样的经验,也并没有什么应用技能或理论思想能和你交流。我在这里,想要与你讲的是“如何学习”。即便鼓了很大的勇气才领下这个题目,也不是因为我很善于学习。我在学习这件事上,是一个只会用笨功夫的人,没有什么技巧能跟你讲。
那我究竟为什么要来讲这个话题,又有什么可以跟你讲的呢?
这就要从同学们的感受说起了。“很难”,这大概是许多同学对月影这门课的直接感受。而这个感受,与引言中所谓“天下最轻易者”实在是天差地别。而我历来认为学习不同于问道:问道恒难,而学习甚易。这便是我想跟你聊一聊的道理。尤其重要的是,只要把这个道理听明白了,我想你学习起来也就轻松了。
正如我前面说过的,这不是学习的技巧,而是学习的道理。
## 为什么你会觉得难
很多人知道,我不是计算机专业出身的。在我决定自学计算机时,也并没有老师领路,因此,我需要自己解决的第一个问题便是:自修的话,先选什么课程。当时我是拿了计算机专业几年的课程表来看,然后把它们之间的关系理顺,最后决定先花一个学期单单只看一门《数据结构》。之所以这么选,是因为其它课程好像都要用到它,看起来也都与它有关。至于这其中的原因,我当时却是不明白的,因为没有人教,也没有人可以问。
那个时候这门课用的是Pascal我没有语言基础学校的电脑上也没有这个语言所以我基本是手写代码脑袋编译。至于一段代码运行的正确性对不起没有上机测试所以我也不知道。就这样忽忽的学了一个学期我去考了试得了70多分。
于是,我的《数据结构》的底子就这样扎下了。自那以后,我在计算机语言、应用这方面,再没遇到比当时更难的困境。这其中的道理很简单,**只要扎实基础,循序渐进,几乎所有语言与代码方面的问题,我们都可以一点点啃下去**。 直到如今,如果有人去看我写的那本《程序原本》,就能看得出这个硬底子还在。
当然,一定会有同学说:我的《数据结构》成绩比你还好,但一样觉得很难啊!
这是正常的。上面我说的这个道理,其实只说了一层。要知道我从业二十多年来,像《数据结构》这样一门一门、一本一本啃下来的课,也并不止一个。如今看起来学什么都容易,只不过是积腋成裘的结果罢了。在这个过程中,但凡我觉得有“非常之疑难”者,无不是中间少啃了那么一本两本“《数据结构》”。也就是说,**那些称得上“疑难不懂”的,其实都只是缺了几门知识,少花了几次笨功夫而已**。所以要学好月影这门课,且先不说他如何讲,你只要笃定一个笨想法就可以了:你看到不懂的名词,必然是少学了几门功课;看到不懂的话语,必然是少推断了几个逻辑,等等如此。
有着这些种种不懂,也就当然有种种能懂的知识。你把这些坑坑洞洞都补起来,自然也就懂了。只要你愿意像在学校里一样地去做笨功夫,这些缺失的知识又怎么算得上难呢?
## 为什么你会听不明白
不过很多人所面对的,倒不见得是“难”,而是所谓“疑”。疑难二字,其实并不是同一个东西:你攀山越岭时,所感觉到的便是“难”,只要努力,一座座地翻过去,总还是可以到达的。而你隔岸观花时,那种似是而非才是“疑”。
之所以有“疑”,并不是目力不济,而是心志不济。
在《[程序原本](https://github.com/aimingoo/my-ebooks)》这本书的前言致谢中,我感谢了我的小学数学老师,因此便有同学问我其中的缘故。我说:那本书中,所用到的数学知识,止于小学足矣。然而这本书却是我在极客时间开设《[JavaScript核心原理解析](https://time.geekbang.org/column/intro/100039701)》一课的基础,所以如今反观这本书、这门课程,其实在数学知识上都是“止于小学足矣”。
所以我很想问,都这样了,为什么还有人觉得“难”?又或者问,既然已是“如此这般地不难”了,那么你所“疑”的,又是什么呢?
但凡我们受过高等教育的,又或者写过几年程序的,都不妨把自己看成是“有知识、有经验”的人。这很正常,我们本来就是靠着这些知识与经验吃饭的,这都是看家底儿的东西。然而,这些东西“正确”吗?**我们之所“疑”的,往往就是所“见到”的与我们所“知道”的之间的矛盾**。比如说在《JavaScript核心原理解析》中第一讲便是说“引用”这个概念的然而ECMAScript中的所谓“引用”与一般语言中的“引用”是似是而非的。所以从这门课一开始就有无数同学深陷于这一个概念之中用既有的知识来解释它有解释为指针的有解释为结构的有解释为类型的……不一而足。
**为什么有“疑”?其实这是缘于我们对“已知”的不疑。**当这种不疑与面前的真相矛盾时,我们就怀疑了、拒绝了、不可接受了。学习中怕的不是看不懂,而是明明表面什么都看得懂,心底里却什么都不接受。这才可怕,这才畏怯,这才寸厘不进。
这也是很多课程“学不明白”的根由:你从一开始,就放不下那些你认为“对”的东西;既然你认为已看到的是对的,那么眼前所学的,又是什么呢?
所以**要解决学习中的“疑”,反倒是放开态度,认为自己什么也不懂,心志坚定地跟着眼前这位老师的步子一点点看,一遍遍练**。这个过程,**并不是要怀疑自己,而是要敢于舍弃自己**。相信他人既然可以为师,必有所长,必不吝于教,无有成见地去学习,才能学有所得,进而更新自己。
在《[大道至简](https://book.douban.com/subject/11874745/)》中说过“问道于盲”的故事:只要你愿意闭上眼睛,你也可以向盲人问道。睁开眼问,这止于眼前的所谓“真相”,便是你的疑难了。
## 为什么你觉得什么也没学会
其实月影这门课还有一个副标题是“系统掌握图形学与可视化核心原理”这与《JavaScript核心原理解析》有一个关键词的重合。并且事实上在我那门课程的开篇中我还专门提到这门课的要点是“体系性系统这算得上另一处巧合。
不过,“核心原理”+“体系性”却并不见得是“你觉得难”的根源。说到这里,恐怕又有不少人要跳起来反对了:都讲“核心原理”了,还不难啊?!
其实核心原理通常来说真的不难。例如密码学,核心原理就是映射变换;图形学,核心原理就是点(位置/坐标)等等。同样的,“体系性”也并不难,例如月影的课程,体系性就摆在那里了,你去看看课程目录就知道了。所以无论是体系,还是核心原理,都不是这些课程让你觉得难的真正原因。
这让我想起了《大道至简》这本书最后有一个“问智”的桥段。起首的一问是“何谓愚”,书中说的是:不知道事情的起始,也不知道它的终点,就是所谓的“愚”了。而在我看来,一门课程要是学不好、学不懂、学不通,起步上的错处,就在你对于要学会的东西“不知其始、非知所终”。
不管是什么课程,你得先知道老师“想讲什么”,这就是结果。如果一个课程你知道了结果,并且又假设结果是对的,那么学习它的方法就是按照老师所讲的内容,一步步推演过去就好了。这个过程是确定的,结果也是确定的,你所需的无非是推演中所缺的几门功课,以及一点耐心而已。所谓前人、所谓先行者,其实无非就是比你多走了一遍路途而已,所以只要我们放下自我的姿态,亦步亦趋地跟着老师讲的往前走,那么这个结果总是能看到的。
学习之“易”也就在于此:总有一个确定的过程,以及一个确定的结果。但是,这往往还不够。因为如果老师只教你这个结果,那也只是最低一等的“师”。无论如何,真正的“良师”,会帮助你解决“不知起始”的问题。——于此,你真正还需要知道的是老师“为什么要教”。一旦你知道了“教的目的”,也就知道了复核自己“学的结果”的方法,这就是相互印证了。如果学习不知道起始、不知道目的、不知道印证,那么怎么学都是茫茫然。
而核心原理和体系性的“易”也就在于此:核心原理总是一个简单的结论,你所学的,只是这个结论得到的过程;体系性总是一种明确的结构(组合),你所学的,只是将这个结构组织起来的方法。所以最终来说,你就是要得到一个核心推论的过程,以及了解它所涉及的那些领域之间的关系而已。
多数情况下,就结果来说,你甚至可以从课程目录,以及每章的小节中直接读到它们。那么不管它与你既有的知识是否有冲突,将这门课程粗略地看过去,拿了这样的结果(核心过程与结构关系)就可以了。
**不过,学习这样的课程,不要在“结果”上花功夫,从过程上去找、去看方法,才是正途。**
## 道理我都懂
正如引言中所说,学习这件事情,其实真的不是很难。至于是不是“天下最轻易者”,我可不敢跟古人去论争。所以在今天的分享里,我也只是想把一些似难实简的地方跟你说说:**其一,你可能只是缺了点知识,要补课;其二,你可能只是宥于所知,请开放思想;其三,你可能只是埋头学习,而忘了要学会的是什么。**
是的,我知道已经有人开始在说:“道理我都懂……”。
这种腹诽,实在是这么些年来我最怕见到的。因为持这种态度者,大多都是不愿下苦功夫的人。所以我希望读到这里,心底下还叹着“道理我都懂”的朋友,能再回到第一小节,从苦功夫做起。要知道**古往今来之有成就者,不过将学习持之以恒而已,哪里有什么技巧**。
不过如果要舍了学习不谈只想拿个现成的东西去用那么作为程序员最好的地方是在GitHub而不是在极客时间。我想这个看起来众所周知的答案便是我对那些急于掩卷而去的人最后的劝慰了。

View File

@@ -0,0 +1,51 @@
<audio id="audio" title="用户故事 | 非前端开发,我为什么要学可视化?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/43/cf/434cdddf4849aa57cdcfb41fd2a500cf.mp3"></audio>
你好,我是月影。专栏已经更新过半啦,首先我想感谢仍然坚持认真学习的你,很高兴见证你的成长。在这个过程中,我的课程对你有哪些帮助呢?你可以写在留言区分享给我。
今天我想和你分享一篇罗同学的故事。罗同学每堂课都会非常认真地完成课后作业。在这里我想对他说很高兴能够看到你分享自己的学习故事谢谢你能喜欢这个专栏。你是一名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讲](https://time.geekbang.org/column/article/255584)所说的“如果我们手中只有解决具体问题的工具,没有统一的方法论,那我们也无法一劳永逸地解决问题的根本”。
比如说我在初中的时候就学了点构成线、线构成面、面构成体这些最基本的数学原理却一直不能在实践中进行应用。课程中的数学基础篇让我重新认识了数学的强大不局限于某一图形系统提供的API图形学的基础知识才是根本。扩展到其他的计算机知识都是这样现在新技术层出不穷让人眼花缭乱而其最本质的知识都是不变的。
而且随着学习的深入我把之前有想过但并没有深入思考过的问题都发掘了出来并且也都有了答案。比如说判断点是否在区域内。我平时在绘图的时候大部分是判断点是否在一些规则图形内部即使碰到了一些不规则图形也是使用现成的API判断如Android 提供了Path、Region浏览器Canvas2D的isPointInPath等。
接着,[第8讲](https://time.geekbang.org/column/article/258101)让我了解到图形学领域有三角剖分这样成熟的技术来解决这类问题。第14讲和第19讲又解开了我困惑已久的如何使用着色器实现几何造型和动画的问题。
在平时的开发过程中,我也会简单使用仿射变换来做图形操作,适当地使用坐标变换,使我们在绘制一些图案时更加简单。而专栏中讲解的生成重复图案、分形图案及随机效果,更加让我体会到了数学知识的强大(离开学校后才意识到数学很有用)。
像学习其他软件编程的时候,我们在初期并不会觉得数学知识很重要,即使是做了几年的软件开发也未必会觉得数学知识在编程中帮助很大。而在图形学中很快就能让人意识到数学很重要,特别是线性代数,我们会经常使用矩阵变换、曲线方程、向量运算等数学原理,甚至还要用到物理学的知识,如实现动画涉及的缓动函数,实现自由落体等模拟现实世界的图形绘制。这也激发了我学习的兴趣,让我觉得这样的技术才是有门槛的。
## 关于学习专栏的一点建议
最后,我想给正在学习专栏的你一点建议,在绘图过程中有很多细节需要我们特别注意,如果只是听听老师的讲解,没有自己动手去尝试,那有很多细节就会留意不到,也就不会引起我们的重视。这门课程对我来说,学习起来确实不是很轻松,我相信也有很多人像我一样,即使我们现在学习起来有点困难,可只要我们把代码拉下来运行一下,调整下参数,再观察图形是否像我们预料的那样,也是能够加速对原理的理解,所以我的建议就是一定要多动手实践。
对底层技术的学习能让我们在技术方面更加地自由、自信,也是我们快速掌握新技术的内功。我认为计算机技术的核心是:操作系统、数据结构和算法、编译原理、计算机网络、计算机图形学。这也是我一直学习的方向。

View File

@@ -0,0 +1,10 @@
你好,我是月影。
截至今天本专栏结课已经快4周了十分感谢你的支持。
那学习了3个多月不知道你有哪些特别想和我说的话呢对于本专栏的内容你还有什么建议或意见为了能更好地了解你的想法以便我后面对课程进行更好地优化在此希望能得到更多的反馈听到更多的声音。
所以我特别邀请你填写毕业问卷另外这也是有奖征集的最后4天啦欢迎大家畅所欲言
[<img src="https://static001.geekbang.org/resource/image/be/41/be76aef82eaa2bd163094b638499ef41.jpg" alt="">](https://jinshuju.net/f/d976Ed)

View File

@@ -0,0 +1,35 @@
<audio id="audio" title="结束语 | 可视化工程师的未来之路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/57/dab1f5484ddd078140c3e72fbefe3057.mp3"></audio>
你好,我是月影。
今天是可视化专栏的最后一节课,这里,我要恭喜你顺利毕业!
从课程上线到今天正式结束我们一起走过了112天。但是从课程开始筹备到今天可远远不止这些日子。这几个月我陪着你从图形学基础学到了向量、参数方程再到后来的视觉、性能和数据篇看着同学们在留言区的进步就算是点点滴滴的进步我都觉得很激动因为我知道这很不容易。
对我来说,只要你通过这几个月的时间,收获了可视化相关的知识、技巧、思想以及编写代码的快乐,就是我交出的一份满意的答卷了。
但是,学习之路永远没有终点,借着这个难得的机会,我还想带你回顾一下整个专栏的学习过程,说说我在讲课过程中的那些顾虑,以及我认为你今后应该重点攻破的地方。专栏虽然结束了,但是未来的路,我依然与你同行!
近几年来,因为产品需求的驱动,可视化方向技术发展也非常迅速,不过在前端整体迅猛发展的大环境下,可视化方向依然属于一个比较小众的领域。我了解到的情况是,作为前端工程师,实际真正从事可视化领域并持续发展的人并不多,甚至可以说是远低于市场的需求的。因此高级和资深的可视化工程师,在行业里是非常有竞争力的。
那为什么优秀的可视化工程师这么少呢实际上阻挡前端工程师往可视化方向深入发展的技术门槛主要有两道一是图形学二是WebGL。图形学主要是因为对使用者的数学基础要求比较高而WebGL主要是因为它更偏底层整体来说比SVG和Canvas等其他的图形系统更加复杂。因此这两道门槛让许多前端工程师对可视化望而却步不敢迈入这个领域。
为了破除这两道门槛在这门课程里我花了很多时间帮你系统地梳理了图形学以及图形学相关的数学知识而且我也详细地为你讲解了WebGL的基础我想你肯定忘不了那些丰富的实战例子和由浅入深的封装过程。通过这些我其实就是想告诉你学习和使用WebGL也那么困难。
在克服了两道技术门槛之后,可视化其他方面的内容我们学习起来就相对容易了。从动画、滤镜、数据加载、渲染性能到图表库、绘图库,这些内容都没有脱离前端的范畴。至于数据处理,以及色彩和可用性原则,它们也与前端领域的内容一脉相承。你只要花点时间多积累些经验,就一定能学好。学好这些内容,对你前端技能的提升也会有很大帮助。
**学习技术的过程,是从接纳和记忆知识开始的,但绝不仅仅是接纳和记忆知识,而是需要深入思考,自己总结和沉淀的。**为了方便你快速吸收知识,我在每节课都做了内容梳理和总结,在每一个独立篇章末尾也列出了脑图,帮助你理解和记忆学过的内容。但实际上,这么做我还是有些顾虑的,因为这些内容都是我自己的沉淀和总结,并不能代替你的学习过程,如果你因此“偷懒”了,光记忆我整理的内容,省略了自己的沉淀和总结,是不可能真正融会贯通可视化领域的知识和技能的。
**就像我说过的如果你只是学会使用图表库的API那么你只是API工程师并不能成为真正的可视化工程师也不能做出真正出色的可视化产品。**同样如果你只是记忆了我教给你的这些知识你也只不过成为了一个高级一点的API工程师并不能真正成为独立创作成功作品的可视化大师。我的期望当然不是让你成为高级一点的API工程师而是希望你在可视化的道路上越走越远。
除此以外,还有些难以用语言表达的东西,一些思考的方法和过程,其实我都把它们融入在课程内容的讲解中了,如果你从中已经得到了一些有用的启发,那么我会很高兴,如果你还没能领悟到更多的内容,你可以在一边工作实践中一边回顾和复习课程内容,我相信你会有更多的收获。如果在学习的过程中,你有任何疑问或者需要其他的帮助,你都可以在留言区与我交流,我非常愿意继续帮助你成长。
我们都知道,持续不断的学习需要强大的自我驱动力,但我想这个行业的光辉未来也能激励我们前行。
随着产品需求和技术的发展可视化领域在未来一段时间会有更多激动人心的变化。比如说5G的普及就能让我们使到用更加复杂的渲染库更加丰富的图像资源而不用担心太大的包导致下载时间过长。再比如说随着AR/VR的推广未来我们可能会将可视化场景从普通的液晶屏幕扩展到虚拟现实中真正的3D场景里。
你可以想象一下在未来可视化应用中我们的用户可以带着3D眼镜在一个与真实场景别无二致的虚拟空间里与各种数据和模型交互。这个时候如果我们能在其中实现更加令人震撼的效果会有多酷是不是想想就非常激动
最后,期待在未来科技中看到你的作品,也希望你成为一名优秀的可视化工程师。这门课到此就正式告一段落了,但生活还将继续,愿与你在可视化工程师的未来之路上相逢!
最后的最后,我还为你准备了一份[毕业调查问卷](https://jinshuju.net/f/d976Ed),题目不多,希望你能花两分钟的时间填一下。一起走过了这些时间,期待听到你对我和这个课程的反馈和建议!

View File

@@ -0,0 +1,309 @@
<audio id="audio" title="10 | 图形系统如何表示颜色?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/fe/5c56781324400fc6bceb028acbc715fe.mp3"></audio>
你好,我是月影。从这一节课开始,我们进入一个全新的模块,开始学习视觉基础。
在可视化领域中图形的形状和颜色信息非常重要它们都可以用来表达数据。我们利用基本的数学方法可以绘制出各种各样的图形通过仿射变换还能改变图形的形状、大小和位置。但关于图形的颜色虽然在前面的课程中我们也使用片元着色器给图形设置了不同的颜色可这只是颜色的基本用法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的三个通道色阶的组合理论上一共能表示2<sup>24</sup> 也就是一共16777216种不同的颜色。因此RGB颜色是将人眼可见的颜色表示为**红、绿、蓝**三原色不同色阶的混合。我们可以用一个三维立方体把RGB能表示的所有颜色形象地描述出来。效果如下图
<img src="https://static001.geekbang.org/resource/image/5f/70/5ff37612dff2e7a89c58fcdc91236270.jpg" alt="" title="RGB的所有颜色">
那RGB能表示人眼所能见到的所有颜色吗事实上RGB色值只能表示这其中的一个区域。如下图所示灰色区域是人眼所能见到的全部颜色中间的三角形是RGB能表示的所有颜色你可以明显地看出它们的对比。
<img src="https://static001.geekbang.org/resource/image/95/6d/950d5507a41978byyd28d32bb81e736d.jpg" alt="" title="人眼看到的颜色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 &lt; 3; i++) {
const colorVector = randomRGB();
for(let j = 0; j &lt; 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数值。这样一组圆就能呈现不同的亮度了。总体上颜色是越左边的越暗越右边的越亮。得到的效果如下
<img src="https://static001.geekbang.org/resource/image/9f/a8/9f8af87f3af968e8e70d0ee09a8a8da8.jpeg" alt="">
但是这么做有两个缺点首先因为这个例子里的RGB颜色是随机产生的所以行与行之间的颜色差别可能很大也可能很小我们无法保证具体的颜色差别大小其次因为无法控制随机生成的颜色本身的亮度所以这样生成的一组圆的颜色有可能都很亮或者都很暗。比如下图中另一组随机生成的圆除了第一行外后面两行的颜色都很暗区分度太差。
<img src="https://static001.geekbang.org/resource/image/33/39/334f2162ab2fc98cae325feacaf6d639.jpeg" alt="">
因此,在需要**动态构建视觉颜色效果**的时候我们很少直接选用RGB色值而是使用其他的颜色表示形式。这其中比较常用的就是HSL和HSV颜色表示形式。
## HSL和HSV颜色
与RGB颜色以色阶表示颜色不同HSL和HSV用色相Hue、饱和度Saturation和亮度Lightness或明度Value来表示颜色。其中Hue是角度取值范围是0到360度饱和度和亮度/明度的值都是从0到100%。
虽然HSL和HSV在[表示方法](https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4#/media/File:Hsl-hsv_models.svg)上有一些区别但它们能达到的效果比较接近。所以就目前来说我们并不需要深入理解它们之间的区别只要学会HSL和HSV通用的颜色表示方法就可以了。
### 1. HSL和HSV的颜色表示方法
HSL和HSV是怎么表示颜色的呢实际上我们可以把HSL和HSV颜色理解为是将RGB颜色的立方体从直角坐标系投影到极坐标的圆柱上所以它的色值和RGB色值是一一对应的。
<img src="https://static001.geekbang.org/resource/image/79/22/79400fe9ded6298b3d7d13f91a64ac22.jpg" alt="" title="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 &lt; 3; i++) {
const p = (i * 0.25 + h) % 1;
for(let j = 0; j &lt; 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的规则亮度越高颜色越接近白色只有同时提升饱和度才能确保圆的颜色不会太浅。
<img src="https://static001.geekbang.org/resource/image/68/a6/68031c6964650bc2f31def0f93fda2a6.jpeg" alt="">
### 2. HSL和HSV的局限性
不过,从上面的例子中你也可以看出来,即使我们可以均匀地修改每组颜色的亮度和饱和度,但这样修改之后,有的颜色看起来和其他的颜色差距明显,有的颜色还是没那么明显。这是为什么呢?这里我先卖个关子,我们先来做一个简单的实验。
```
for(let i = 0; i &lt; 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 &lt; 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%,最终生成的效果如下:
<img src="https://static001.geekbang.org/resource/image/67/61/674ae06ae45050bb2e9840a1c081b661.jpeg" alt="">
先看第一排圆你会发现虽然它们的色相相差都是15但是相互之间颜色变化并不是均匀的尤其是中间几个绿色圆的颜色比较接近。接着我们再看第二排圆虽然这些圆的亮度都是50%,但是蓝色和紫色的圆看起来就是不如偏绿偏黄的圆亮。这都是由于人眼对不同频率的光的敏感度不同造成的。
因此HSL依然不是最完美的颜色方法我们还需要建立一套针对人类知觉的标准这个标准在描述颜色的时候要尽可能地满足以下2个原则
1. 人眼看到的色差 = 颜色向量间的欧氏距离
1. 相同的亮度,能让人感觉亮度相同
于是一个针对人类感觉的颜色描述方式就产生了它就是CIE Lab。
## CIE Lab和CIE Lch颜色
CIE Lab颜色空间简称Lab它其实就是一种符合人类感觉的色彩空间它用L表示亮度a和b表示颜色对立度。RGB值也可以Lab转换但是转换规则比较复杂你可以通过[wikipedia.org](https://en.wikipedia.org/wiki/CIELAB_color_space)来进一步了解它的基本原理。
CIE Lab比较特殊的一点是目前还没有能支持CIE Lab的图形系统但是[css-color level4](https://www.w3.org/TR/css-color-4/#funcdef-lab)规范已经给出了Lab颜色值的定义。
```
lab() = lab( &lt;percentage&gt; &lt;number&gt; &lt;number&gt; [ / &lt;alpha-value&gt; ]? )
```
而且一些JavaScript库也已经可以直接处理Lab颜色空间了如[d3-color](https://github.com/d3/d3-color)。下面我们通过一个代码例子来详细讲讲d3.lab是怎么处理Lab颜色的。如下面代码所示我们使用d3.lab来定义Lab色彩。这个例子与HSL的例子一样也是显示两排圆形。这里我们让第一排相邻圆形之间的lab色值的欧氏空间距离相同第二排相邻圆形之间的亮度按5阶的方式递增。
```
/* global d3 */
for(let i = 0; i &lt; 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 &lt; 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();
}
```
代码最终的运行效果如下:
<img src="https://static001.geekbang.org/resource/image/9c/92/9c0898db90d53fe4fd3e6b3c83043d92.jpeg" alt="">
你会发现在以CIELab方式呈现的色彩变化中我们设置的数值和人眼感知的一致性比较强。
而CIE Lch和CIE Lab的对应方式类似于RGB和HSL和HSV的对应方式也是将坐标从立方体的直角坐标系变换为圆柱体的极坐标系这里就不再多说了。CIE Lch和CIE Lab表示颜色的技术还比较新所以目前我们也不会接触很多但是因为它能呈现的色彩更贴近人眼的感知所以我相信它会发展得很快。作为技术人这些新技术我们也要持续关注。
## Cubehelix色盘
最后我们再来说一种特殊的颜色表示法Cubehelix色盘立方螺旋色盘。简单来说它的原理就是在RGB的立方中构建一段螺旋线让色相随着亮度增加螺旋变换。如下图所示
<img src="https://static001.geekbang.org/resource/image/28/0c/281b8bbdeebyy0f3e62c80278267150c.jpg" alt="" title="Cubehelix色盘的原理">
我们还是直接来看它的应用。接下来我会直接用NPM上的[cubehelix](https://www.npmjs.com/package/cubehelix)模块写一个颜色随着长度变化的柱状图你可以通过它来看看Cubehelix是怎么应用的。效果如下
<img src="https://static001.geekbang.org/resource/image/4d/ef/4d71b84fc3282e84a98141e444f658ef.gif" alt="">
它的实现代码也非常简单,我来简单说一下思路。
首先我们直接使用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色盘绘制出来吗
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[本节课示例代码完整版](https://github.com/akira-cn/graphics/tree/master/color-hints)
## 推荐阅读
颜色也是可视化非常重要的内容,所以这节课的知识点比较多,参考资料也很多。如果你有兴趣深入研究,我建议你一定要认真看看我给的这些资料。
[1] [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4)<br>
[2] [RGB color model](https://www.wikiwand.com/en/RGB_color_model)<br>
[3] [HSL和HSV色彩空间](https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4)<br>
[4] [色彩空间中的 HSL、HSV、HSB的区别](https://www.zhihu.com/question/22077462)<br>
[5] [用JavaScript实现RGB-HSL-HSB相互转换的方法](http://wsyks.github.io/2017/03/17/JS%E5%AE%9E%E7%8E%B0RGB-HSL-HSB%E7%9B%B8%E4%BA%92%E8%BD%AC%E6%8D%A2/)<br>
[6] [Lab色彩空间维基百科](https://zh.wikipedia.org/wiki/Lab%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4)<br>
[7] [Cubehelix颜色表算法](https://zhuanlan.zhihu.com/p/121055691)<br>
[8] [Dave Greens `cubehelix colour scheme](http://www.mrao.cam.ac.uk/~dag/CUBEHELIX/)<br>
[9] [d3-color官方文档](https://d3js.org.cn/document/d3-color)

View File

@@ -0,0 +1,473 @@
<audio id="audio" title="11图案生成如何生成重复图案、分形图案以及随机效果" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/b4/yy9cf73e08749e962f1a96da9a037ab4.mp3"></audio>
你好,我是月影。
图案生成是可视化中非常重要的基础。有多重要呢?我们知道,可视化中的几何图形是用来表达数据的,那图案就是用来修饰这些几何图形,强化视觉效果的,所以图案一般是指几何图形上的花纹。这些花纹有的简单,有的复杂,有的规律明显,有的看上去比较随机。也正是因为图案可以如此的不同,它们才能更好地增强视觉效果。
这一节课,我们就来聊一聊图案生成的基本原理和方法论。不过,因为可视化中的图案非常多,所以今天我们主要来讲三种最常用的,分别是重复图案、分形图案和随机图案。
首先,我们来看重复图案。
## 如何绘制大批量重复图案
在可视化应用中,我们经常会使用重复图案。比如说,我们在显示图表的时候,经常会给背景加上一层网格,这样可以辅助用户阅读和理解图表数据。
<img src="https://static001.geekbang.org/resource/image/61/7a/6169388ac17a338ab1bf8b40d93c657a.jpeg" alt="" title="带有网格背景的画布">
那像网格这样经典的重复图案我们应该怎样绘制它呢这些网格看起来像是由一条一条线段组成的是不是利用绘制线段的方式比如我们之前学过的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机制就可以实现我们想要的网格背景了。
<img src="https://static001.geekbang.org/resource/image/07/92/077771d23ac719dc2d4dd10552b41192.jpeg" alt="" title="网格图案效果">
总结来说这种利用了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](https://github.com/akira-cn/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就可以了。
<img src="https://static001.geekbang.org/resource/image/75/07/756d265d782a6d9a706db049ayy4f607.jpeg" alt="" title="顶点坐标和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个周期的数值。
<img src="https://static001.geekbang.org/resource/image/55/27/55ff744280b589147b8b7f1f5acdf527.jpeg" alt="" title="y = fract(x) 在整数区间内周期重复示意图">
所以这里我们用vUv也就是由顶点着色器传来的uv属性纹理坐标乘上rows值然后用fract取小数部分就能得到st了。
接着我们处理st的x和y。因为WebGL中的片元着色器线性插值所以现在它们默认是线性变化的而我们要的是阶梯变化。那要实现阶梯变化我们可以使用step函数step函数是Shader中另一个很常用的函数它就是一个阶梯函数。它的原理是当step(a, b)中的b &lt; a时返回0当b &gt;= a时返回1。
<img src="https://static001.geekbang.org/resource/image/6d/52/6dcd4de27bb6753274814a6b0e8c8852.jpeg" alt="" title="Step函数">
因此d1和d2分别有2种取值情况。
<img src="https://static001.geekbang.org/resource/image/9b/5e/9baa897a9180ce776b72cb90a8cfd45e.jpg" alt="">
最后我们要根据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时的效果都绘制出来了你可以看看。
<img src="https://static001.geekbang.org/resource/image/19/e6/19fd5561b5ac779f1de9d49c4d8bbbe6.gif" alt="" title="rows为1、4、16、32、64的效果">
这就是我们用Shader实现重复图案的完整过程。它的优势在于不管我们给rows取值多少图案都是一次绘制出来的并不会因为rows增加而消耗性能。所以使用Shader绘制重复图案不管绘制多么细腻图案重复多少次绘制消耗的时间几乎是常量不会遇到性能瓶颈。
## 如何绘制分形图案
说完了重复图案,我们再来说分形。它不仅是自然界中存在的一种自然现象,也是一种优美的数学模型。通俗点来说,一个分形图案可以划分成无数个部分,而每个部分的形状又都和这个图案整体具有相似性。所以,典型的分形效果具有局部与整体的自相似性以及无限细节(分形可以无限放大),能产生令人震撼的视觉效果。
<img src="https://static001.geekbang.org/resource/image/34/64/341085ab7bf1076255a3d151e563cc64.jpg" alt="" title="自然界中的分形:罗马花椰菜">
实际上分形在实践中偏向于视觉和UI设计。虽然它在实际的可视化项目中不太常用但总能够起到画龙点睛的作用。所以了解分形在视觉呈现中的实现技巧还是很有必要的。下面我们就来详细讲讲分形是怎么实现的。
首先我们来认识一下分形公式Mandelbrot Set也叫曼德勃罗特集。它是由美国数学家曼徳勃罗特教授发现的迭代公式构成的分形集合。这个公式中Z<sub>n</sub>和Z<sub>n+1</sub>是复数C是一个实数常量。
<img src="https://static001.geekbang.org/resource/image/db/77/db02b5eddd8721e9f5299853ace9d077.jpeg" alt="">
这个迭代公式使用起来非常简单,只要我们给定一个初始值,它就能产生许多有趣的图案。接下来,我们就一起来看一个有趣的例子。
首先我们实现一个片元着色器,代码如下:
```
#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 &lt; 65536; i++) {
if(i &gt; iterations) break;
j = i;
z = f(z, c);
if (length(z) &gt; 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();
```
<img src="https://static001.geekbang.org/resource/image/0d/5a/0d33586fd494f7ba55cfe17a7e9e105a.jpeg" alt="" title="画布上最终的渲染结果">
这个图案本身似乎没有什么特别的效果我们可以修改一下Shader中的代码改变渲染颜色的规则根据迭代次数和迭代深度的比值来渲染不同的颜色然后将它局部放大就能得到非常有趣的图案了。
<img src="https://static001.geekbang.org/resource/image/b6/9e/b61a71e0de654958d1ee033b3b0a939e.gif" alt="">
## 如何给图案增加随机效果
那分形图案为什么这么吸引人呢?如果你多看几个,就会发现,它们的无限细节里同时拥有重复和随机这两个规律。那对于其他非分形的图案,如果也想让它变得吸引人,我们其实可以给它们增加**随机效果**。
不知道,你还记得我们开篇词中的那个抽奖程序吗?实际上它就是一个随机效果的应用。
<img src="https://static001.geekbang.org/resource/image/e7/eb/e7025fcba897df3b78205d1d711cb9eb.jpg" alt="" title="产生随机色块的抽奖程序">
要想实现类似这样的随机效果在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;
}
```
它的执行结果是一片噪点,效果如下图所示。
<img src="https://static001.geekbang.org/resource/image/65/22/65539f5eda08e26a22caaf84a66f5822.jpeg" alt="">
这些噪点显然不能满足我们想要的随机效果因为它们只有一个像素而且太小了。所以下一步我们可以用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函数给每一个方块随机一个颜色最终实现的结果如下
<img src="https://static001.geekbang.org/resource/image/ac/c5/ac66f16a9d1fd8baf0ed86442462fbc5.jpeg" alt="">
此外我们还可以结合随机和动态效果。具体的方法就是传入一个代表时间的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;
}
```
<img src="https://static001.geekbang.org/resource/image/8c/9c/8cee3889c8034f0d94211fcc1ec72b9c.gif" alt="">
除此之外我们用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 &gt; 0.75) {
_st = vec2(1.0) - _st;
} else if (_index &gt; 0.5) {
_st = vec2(1.0-_st.x,_st.y);
} else if (_index &gt; 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);
}
```
<img src="https://static001.geekbang.org/resource/image/a3/3b/a3ae93b53bb2e3992267a1c996a5583b.jpeg" alt="">
## 要点总结
今天,我们讲了可视化中三种常用图案的生成原理。
第一种批量重复图案。一般来说在绘制批量重复图案的时候我们可以采用2种方案。首先是使用CSS的background-image属性利用backgroud-repeat快速重复绘制。其次我们可以使用片元着色器利用GPU的并行渲染的特点来绘制。
第二种,分形图案。绘制分形图案有一个可以直接的公式,曼德勃罗特集。我们可以使用它来绘制分形图案。
第三种是在重复图案上增加随机性,我们可以在片元着色器中使用伪随机函数,来给重复图案实现随机效果。
虽然我们说几何图形是用来承载数据信息,图案是来强化视觉效果的,但实际上,它们也并没有绝对的界限,有时候我们也可以将图案与数据信息一起管理。比如说,在上面那个动态效果的例子中,我们可以调整动态参数,让图形的运动速度或者黑白块的分布和数据量或者信息内容联系起来。这会大大强化可视化的视觉效果,从而加深用户对信息的理解。
在这一节课我们讲了大量关于WebGL的片元着色器的知识。这是因为片元着色器是最适合生成和绘制这些图案的技术但这也并不意味着用其他图形系统比如SVG或者Canvas就没法很好地生成并绘制这些图案了。
实际上它们的基本原理是相同的所以用SVG或Canvas同样可以绘制这些图案。只不过因为SVG和Canvas渲染不能深入控制GPU底层所以就没法做到像WebGL这样并行高效地渲染这些图案。那如果在选择SVG和Canvas的可视化应用中需要绘制大量的这些图案就必然会导致性能瓶颈这也是为什么我们一定要了解和掌握WebGL技术只有这样我们才能真正掌握绘制极有视觉冲击力的复杂图案的能力。
最后,我还要啰嗦几句,如果你对片元着色器应用还不是很熟悉,对上面的代码还有疑问或者不是很理解,那也没有关系,你可以花一点时间,仔细研究一下[GitHub 仓库](https://github.com/akira-cn/graphics/tree/master/repeat-and-random)的源代码。要记住,动手实践永远是我们最好的学习方式,没有之一。
另外,在接下来的课程里,我们还会大量使用片元着色器创建更多有趣、炫酷的视觉效果。所以,我也建议你去从头看看这份关于片元着色器的学习资料,[The Book of Shaders](https://thebookofshaders.com/?lan=ch),相信你会非常有收获。
## 小试牛刀
<li>
在前面的例子里我们实现了一个10*10的灰色块方阵这里我们使用的是灰度颜色你能够渲染出彩色方块吗你可以尝试将随机数映射成HSV坐标中的H然后绘制出不同的彩色方阵。
</li>
<li>
在实现抽奖程序的时候我们在Shader中使用的是伪随机函数random。那如果要实现真正的随机数我们该怎么做呢如果我们希望实现的迷宫图案在我们每次刷新网页的时候都不相同这个功能你可以实现吗你可以fork GitHub仓库的代码然后把伪随机迷宫图案修改成真正随机迷宫图案然后把你的代码和实际效果分享出来。
</li>
<li>
我们知道使用background-image的弊端是当我们用坐标变换来缩放或移动图形的时候作为元素背景的网格是不会随着缩放或移动而改变的。但使用Shader我们就能够避免这个问题了。
</li>
不过,我们在课程中没有给出缩放和移动图形的例子。你能够扩展我给出的例子,实现图案随着图形的缩放和移动变化的效果吗(这里,我再给你一个小提示,你可以使用顶点着色器和仿射变换矩阵来实现)?
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程示例代码](https://github.com/akira-cn/graphics/tree/master/repeat-and-random)
## 推荐阅读
[1]基于WebGL底层简单封装的基础库 [gl-renderer]的官方文档([https://github.com/akira-cn/gl-renderer](https://github.com/akira-cn/gl-renderer)) 它可以大大简化WebGL代码的书写难度<br>
[2]很棒的学习片元着色器的教程 [The Book of Shaders](https://thebookofshaders.com/?lan=ch) .

View File

@@ -0,0 +1,617 @@
<audio id="audio" title="12 | 如何使用滤镜函数实现美颜效果?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/ec/e8e94c2c522f7699f38fbfea65f385ec.mp3"></audio>
你好,我是月影。
通过前面的课程,我们初步了解了浏览器的图形系统,也学会了使用基本的数学和几何方法来生成和处理图像,还能用简单的图形组合来构成复杂的图案。从这一节课开始,我们进入一个新的模块,开始学习像素处理。
在可视化领域里,我们常常需要处理大规模的数据,比如,需要呈现数万甚至数十万条信息在空间中的分布情况。如果我们用几何绘制的方式将这些信息一一绘制出来,性能可能就会很差。
这时,我们就可以将这些数据简化为像素点进行处理。这种处理图像的新思路就叫做**像素化。**在可视化应用中,图片像素化处理是一个很重要手段,它能够在我们将原始数据信息转换成图形后,进一步处理图形的细节,突出我们想要表达的信息,还能让视觉呈现更有冲击力。
因为像素化的内容比较复杂,能做的事情也非常多,所以我们会用五节课的时间来讨论。今天是第一节课,我们先来看看图片像素化的基本思路和方法,体会如何用像素化来处理照片,从而达到“美颜”的效果。
## 如何理解像素化?
首先我们来理解两个基础的概念。第一个是像素化。所谓像素化就是把一个图像看成是由一组像素点组合而成的。每个像素点负责描述图像上的一个点并且带有这个点的基本绘图信息。那对于一张800像素宽、600像素高的图片来说整张图一共就有48万个像素点。
<img src="https://static001.geekbang.org/resource/image/a4/e5/a4ba75ac8c1a1f1d05711f50eeb6fae5.jpeg" alt="">
这么多的像素点是怎么存储的呢Canvas2D以4个通道来存放每个像素点的颜色信息每个通道是8个比特位也就是0~255的十进制数值4个通道对应RGBA颜色的四个值。后面我们会用RGBA通道来分别代表它们。
知道了什么是像素化,那像素处理又是怎么一回事呢?像素处理实际上就是我们为了达到特定的视觉效果,用程序来处理图像上每个像素点。像素处理的应用非常广泛,能实现的效果也非常多。下面,我会列举几个常用的效果,希望你通过它们能理解像素处理的一般方法和思路。
### 应用一:实现灰度化图片
在可视化中,当我们要给用户强调某些信息的时候,一般会将我们不想强调的信息区域置成灰度状态。这样,用户就能够快速抓住我们想要表达的重点了。这个过程就是**灰度化图片**。简单来说就是将一张彩色图片变为灰白色图片。具体的实现思路是我们先将该图片的每个像素点的R、G、B通道的值进行加权平均然后将这个值作为每个像素点新的R、G、B通道值具体公式如下
<img src="https://static001.geekbang.org/resource/image/8e/60/8e1d92356aa99ca858c9fd13336c1660.jpeg" alt="">
其中R、G、B是原图片中的R、G、B通道的色值V是加权平均色值a、b、c是加权系数满足 (a + b + c) = 1。
好了灰度化的原理你已经知道了下面我们通过一个具体的例子来演示一下实际操作的过程。首先我们写一段简单的JavaScript代码通过这段代码把一张图片给加载并绘制到Canvas上代码如下
```
&lt;canvas id=&quot;paper&quot; width=&quot;0&quot; height=&quot;0&quot;&gt;&lt;/canvas&gt;
&lt;script&gt;
function loadImage(src) {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) =&gt; {
img.onload = () =&gt; {
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);
}());
&lt;/script&gt;
```
这段代码开始创建一个image元素然后通过image元素的src属性异步加载图片加载完成后通过canvas的2d上下文对象的drawImage方法将图片元素绘制到canvas上。这张图片如下图所示
<img src="https://static001.geekbang.org/resource/image/d2/36/d22c3ff49fd473b4cb6b52f036542a36.jpg" alt="">
接下来我们要获取每个像素点的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。<strong>这是因为图片一共是**width * height</strong>个像素点每个像素点有4个通道所以总长度是像素点的4倍。
知道了数组长度以后接着我们就可以遍历data数组读取每个像素的RGBA四通道的值将原本的RGBA值都用一个加权平均值**0.2126 r + 0.7152 g + 0.0722 * b**替代了,代码如下:
```
for(let i = 0; i &lt; 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中去。这样我们就得到了一张经过灰度化后的图片。
<img src="https://static001.geekbang.org/resource/image/2a/35/2a4d672f1dc87f951a4c7ea2e9871335.jpg" alt="" title="灰度化后的图片">
灰度化图片的过程非常简单我们一起来总结一下。首先我们加载一张图片将它绘制到canvas接着我们通过getImageData获取imageData信息再通过imageData.data遍历图像上的所有像素点对每个像素点的RGBA值进行加权平均处理然后将处理好的信息回写到canvas中去。
我把灰度化图片的过程总结了一张流程图,你也可以参考它来理解。
<img src="https://static001.geekbang.org/resource/image/81/68/81a8ef241e0d0f0924540b57a7d52f68.jpg" alt="">
### 重构代码以扩展其他效果
实际上,灰度化只是像素颜色处理中一个最简单的应用,除此以外,我们还可以对像素颜色做其他变换,比如增强或减弱某个通道的色值,改变颜色的亮度、对比度、饱和度、色相等等。
那为了方便讲解,也为了更好地复用代码实现其他的功能,我先重构一下上面的代码,将我们最关注的**循环处理每个像素的颜色信息**这一步单独剥离出来,再把其他步骤都**分解并抽象成通用的模块**以便于实现其他效果的时候引入。
重构代码的实现思路是,先创建一个**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) =&gt; {
img.onload = () =&gt; {
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 &lt; 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 =&gt; 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}) =&gt; { // 对每个像素进行灰度化处理
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通道的原色值将每个通道的色值映射为一个新色值。
<img src="https://static001.geekbang.org/resource/image/83/eb/8325c3e173c27652172feed71991dfeb.jpeg" alt="">
除了加权平均的计算公式以外我们还可以用其他的线性方程组来实现各种不同的像素变换效果。比如说要想实现改变图片的亮度我们可以将R、G、B通道的值都乘以一个常量p公式如下
<img src="https://static001.geekbang.org/resource/image/8b/7f/8b692dd0dafa34bdeab5f136a777457f.jpeg" alt="">
这里的p是一个常量如果它小于1那么R、G、B值就会变小图片就会变暗也就更接近于黑色了。相反如果p大于1图片就会变亮更接近白色。这样一来我们用不同的公式就可以将像素的颜色处理成我们所期望的结果了。
但如果你想要实现不同的颜色变换,就必须要使用不同的方程组,这会让我们使用起来非常麻烦。那你肯定想问了,有没有一种方式,可以更通用地实现更多的颜色变换效果呢?当然是有的,我们可以引入一个颜色矩阵,它能够处理几乎所有的颜色变换类滤镜。
我们创建一个4*5颜色矩阵让它的第一行决定红色通道第二行决定绿色通道第三行决定蓝色通道第四行决定Alpha通道。
<img src="https://static001.geekbang.org/resource/image/a1/88/a1d08c1750cd074c4a064635f30e6188.jpeg" alt="">
那如果要改变一个像素的颜色效果,我们只需要将该矩阵与像素的颜色向量相乘就可以了。
<img src="https://static001.geekbang.org/resource/image/54/5f/54849d44dc3036e8fa7d7b92b8c0885f.jpeg" alt="">
这样一来,灰度化图片的处理过程,就可以描述成如下的颜色矩阵:
```
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**方法呢?
这是因为根据矩阵运算的性质,我们可以将多次颜色变换的过程,简化为将相应的颜色矩阵相乘,然后用最终的那个矩阵对颜色值进行映射。具体的过程你可以看看我给出的流程图。
<img src="https://static001.geekbang.org/resource/image/cd/02/cd38c0987e56d1d93175c623f641d502.jpg" alt="">
这样,灰度化图片的实现部分就可以写成如下代码:
```
...省略代码...
traverse(imageData, ({r, g, b, a}) =&gt; {
return transformColor([r, g, b, a], grayscale(1));
});
...省略代码...
```
这里的**grayscale函数**返回了实现灰度化的颜色矩阵,而要实现其他颜色变换效果,我们可以定义其他函数返回其他的颜色矩阵。这种返回颜色矩阵的函数,我们一般称为**颜色滤镜函数**。
抽象出了颜色滤镜函数之后,我们处理颜色代码的过程可以规范成如下图所示的过程:
<img src="https://static001.geekbang.org/resource/image/57/f9/57cc41d94e5ca657a906571d99e5c0f9.jpg" alt="">
我们还可以增加其他的颜色滤镜函数,比如:
```
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}) =&gt; {
return transformColor([r, g, b, a], channel({r: 1.5, g: 0.75}));
});
```
举个例子当我们调用channel({r: 1.5, g: 0.75})的时候红色通道的值被映射为原来的1.5倍绿色通道的值则被映射为0.75倍。这样得到的图片就显得比原来要红。
<img src="https://static001.geekbang.org/resource/image/bf/ed/bfd11b7f7d3f59b9a3b8d5bb2fd1faed.jpg" alt="">
在处理图片时我们还会用到有一些常用的颜色滤镜比如可以修改图片的亮度Brightness、饱和度Saturate、对比度Constrast、透明度Opacity还有对图片反色Invert和旋转色相HueRotate。这些滤镜函数的结构都是一样的只是返回的矩阵不同而已。
它们的滤镜函数如下,你结合上节课我们学过的颜色理论很容易就可以理解了,我就不详细讲了。
```
// 改变亮度p = 0 全暗p &gt; 0 且 p &lt; 1 调暗p = 1 原色, p &gt; 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 &gt; 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 &lt; 1 减弱对比度p &gt; 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) =&gt; multiply(m1, m2));
...
}
```
所以如果在一些需要暖色调的场景中我们想让一张图片变得有“阳光感”那我们可以使用叠加channel函数中的红色通道、brightness函数和 saturate函数来实现这一效果。
具体的实现代码和效果如下所示。
```
traverse(imageData, ({r, g, b, a}) =&gt; {
return transformColor(
[r, g, b, a],
channel({r: 1.2}), // 增强红色通道
brightness(1.2), // 增强亮度
saturate(1.2), // 增强饱和度
);
});
```
<img src="https://static001.geekbang.org/resource/image/e2/28/e296b8a0069ffb3030865525e5356f28.jpg" alt="">
### 应用三:使用高斯模糊对照片美颜
你肯定发现了刚才我们讲的颜色滤镜都比较简单。没错其实它们都是一些简单滤镜。那在实际的可视化项目中我们通常会使用颜色滤镜来增强视觉呈现的细节而用一种相对复杂的滤镜来模糊背景从而突出我们要呈现给用户的内容。这个复杂滤镜就叫做高斯模糊Gaussian Blur
高斯模糊的原理与颜色滤镜不同,高斯模糊不是单纯根据颜色矩阵计算当前像素点的颜色值,而是会按照高斯分布的权重,对当前像素点及其周围像素点的颜色按照高斯分布的权重加权平均。这样做,我们就能让图片各像素色值与周围色值的差异减小,从而达到平滑,或者说是模糊的效果。所以,高斯模糊是一个非常重要的**平滑效果滤镜**Blur Filters
<img src="https://static001.geekbang.org/resource/image/17/3d/1710fef0c46687byy6e9b7f816e48c3d.jpeg" alt="">
高斯模糊的算法分两步,第一步是生成高斯分布矩阵,这个矩阵的作用是按照高斯函数提供平滑过程中参与计算的像素点的加权平均权重。代码如下:
```
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 &lt;= radius; x++) {
const g = a * Math.exp(b * x ** 2);
matrix.push(g);
sum += g;
}
for(let i = 0, len = matrix.length; i &lt; len; i++) {
matrix[i] /= sum;
}
return {matrix, sum};
}
```
那高斯分布的原理是什么呢?它其实就是正态分布,简单来说就是将当前像素点的颜色值设置为附近像素点颜色值的加权平均,而距离当前像素越近的点的权重越高,权重分布满足正态分布。
<img src="https://static001.geekbang.org/resource/image/5a/7c/5af394600f578789yy9b73768f18fc7c.jpeg" alt="">
因为高斯分布涉及比较专业的数学知识,所以要展开细讲会非常复杂,而且我们在实际工作中只要理解原理并且会使用相关公式就够了。因此,你要记住我这里给出的二维高斯函数公式。
<img src="https://static001.geekbang.org/resource/image/41/f5/4134360e2c0105594491b9702eb036f5.jpeg" alt="" title="二维高斯函数">
这个公式其实就是上面代码中的计算式:
```
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轴两个方向上分别进行高斯运算**。也就是沿着图片的宽、高方向对当前像素和它附近的像素,应用上面得出的权重矩阵中的值进行加权平均。
<img src="https://static001.geekbang.org/resource/image/77/16/77f99e50ffa97e3359bdea697a92f616.jpeg" alt="">
实现代码如下:
```
export function gaussianBlur(pixels, width, height, radius = 3, sigma = radius / 3) {
const {matrix, sum} = gaussianMatrix(radius, sigma);
// x 方向一维高斯运算
for(let y = 0; y &lt; height; y++) {
for(let x = 0; x &lt; width; x++) {
let r = 0,
g = 0,
b = 0;
for(let j = -radius; j &lt;= radius; j++) {
const k = x + j;
if(k &gt;= 0 &amp;&amp; k &lt; 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 遍历函数的方法,而是整体对图片所有像素应用高斯模糊函数。我们最终得到的效果如下图:
<img src="https://static001.geekbang.org/resource/image/40/27/40f674c8b282712490a3a44c1926cc27.jpg" alt="">
你会发现,图片整体都变得“模糊”了。那这种模糊效果就完全可以用来给图片“磨皮”。处理后的图片对比效果如下:
<img src="https://static001.geekbang.org/resource/image/8f/f5/8fa0dbf5e0514193c0b8b15e5ea294f5.jpg" alt="">
在这里,我们不仅给图片加了高斯模糊,还用灰度化、增强了饱和度、增强了对比度和亮度。这样图片上的人的皮肤就会显得更白皙。我建议你,可以试着自己动手来实现看看。
## 像素化与CSS滤镜
那在上面这几个例子中我们都是自己通过Canvas的getImageData方法拿到像素数据然后遍历读取或修改像素信息。实际上如果只是按照某些特定规则改变一个图像上的所有像素浏览器提供了更简便的方法CSS**滤镜**。
前面讲过的几种图片效果我们都可以通过CSS滤镜实现。比如灰度化图片可以直接使用img元素来代码如下
```
&lt;img src=&quot;https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg&quot; style=&quot;filter:grayscale(100%)&quot;&gt;
```
同样美颜效果我们也可以用CSS滤镜来实现
```
&lt;img src=&quot;https://p0.ssl.qhimg.com/t01161037b5fe87f236.jpg&quot;
style=&quot;filter:blur(1.5px) grayscale(0.5) saturate(1.2) contrast(1.1) brightness(1.2)&quot;&gt;
```
除此以外比较新的浏览器上还实现了原生的Canvas滤镜与CSS滤镜相对应。[CSS滤镜](https://developer.mozilla.org/zh-CN/docs/Web/CSS/filter)和[Canvas滤镜](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/filter)都能实现非常丰富的滤镜效果在处理视觉呈现上很有用。尽管CSS滤镜和Canvas滤镜都很好用但是在实现效果上都有局限性它们一般只能实现比较固定的视觉效果。这对于可视化来说这并不够用。
这个时候像素处理的优势就体现出来了,用像素处理图片更灵活,因为它可以实现滤镜功能,还可以实现更加丰富的效果,包括一些非常炫酷的视觉效果,这正是可视化领域所需要的。
## 要点总结
这一节课我们学习了图片像素处理的基本原理。你要掌握的核心概念有滤镜函数、高斯模糊滤镜以及内置滤镜。
在像素处理的过程中我们可以利用Canvas的getImageData API来获取图片的像素数据然后遍历图片的每个像素点最后用线性方程或者矩阵变换来改变图片的像素颜色。我们可以定义函数来生成矩阵变换这些生成矩阵变换的函数就是滤镜函数。
像grayscale一类的函数就是比较简单的滤镜函数了。除此以外我们还学习了一类复杂滤镜其中最基础的一种是高斯模糊滤镜我们可以用它的平滑效果来给照片“美颜”。实际上高斯模糊滤镜经常会用来做背景模糊以突出主题内容。
而且我们还探讨了像素化的优势虽然浏览器提供了内置的Canvas滤镜和CSS滤镜可以实现大部分滤镜函数的功能我们可以直接使用它们。但是内置滤镜实现不了一些更加复杂的视觉效果而像素化可以。不过我也会在后面的课程中继续和你深入讨论一些复杂的滤镜用它们来生成复杂效果。
## 小试牛刀
## 你能利用鼠标事件和今天学过的内容,做出一个图片局部“放大器”的效果吗?具体的效果就是,在鼠标移动在图片上时,将图片以鼠标坐标为圆心,指定半径内的内容局部放大。
## 源码
[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/pixels)
## 推荐阅读
[《高斯模糊的算法》](http://www.ruanyifeng.com/blog/2012/11/gaussian_blur.html)
欢迎留言和我分享你的练习过程,如果有收获,欢迎你把这节课分享给你的朋友。

View File

@@ -0,0 +1,412 @@
<audio id="audio" title="13 | 如何给简单的图案添加纹理和复杂滤镜?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/a7/7ea0b6c1c2c2624016125fd6522f9ea7.mp3"></audio>
你好,我是月影。
上一课我们讲了两类处理像素的滤镜,分别是颜色滤镜和高斯滤镜。其中,**颜色滤镜是基本的简单滤镜**。因为简单滤镜里的每个像素都是独立的,所以它的处理结果**不依赖于其他像素点的信息**,因此应用起来也比较简单。**而高斯滤镜也就是平滑效果滤镜**,它是最基本的**复杂滤镜**。复杂滤镜的处理结果不仅与当前像素有关,还与其周围的像素点有关,所以应用起来很复杂。
当然了,颜色滤镜和高斯滤镜能够实现的视觉效果有限。如果想要实现更复杂的视觉效果,我们还需要使用更多其他的滤镜。所以这一节课,我们就来说说,怎么结合不同滤镜实现更复杂的视觉效果。
## 其他简单滤镜在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}) =&gt; {
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);
}());
```
如上面代码所示,我们可以在遍历像素点的时候计算当前像素点到图片中心点的距离,然后根据距离设置透明度,这样我们就可以实现下面这样的边缘模糊效果了。
<img src="https://static001.geekbang.org/resource/image/33/c0/33450156f67efc94d33e6f93ea8b93c0.jpg" alt="" title="边缘模糊效果示意图">
**第二个,我们可以利用像素处理实现图片融合**。比如说我们可以给一张照片加上阳光照耀的效果。具体操作就是把下面这张透明的PNG图片叠加到一张照片上。
<img src="https://static001.geekbang.org/resource/image/fe/1e/fe66ffff1ff6bd738bbb3ca4e037671e.jpg" alt="" title="纹理">
这种能叠加到其他照片上的图片,通常被称为**纹理**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}) =&gt; {
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);
}());
```
<img src="https://static001.geekbang.org/resource/image/5c/71/5cbf83d6217e5a1062e4c8380af85271.jpg" alt="" title="阳光照耀效果图">
另外,我们还可以选择不同的图片,来实现不同的纹理叠加效果,比如爆炸效果、水波效果等等。
<img src="https://static001.geekbang.org/resource/image/2c/7f/2c74538c71c23577a8d87b572335c57f.jpg" alt="" title="爆炸效果图">
纹理叠加能实现的效果非常多所以它也是像素处理中的基础操作。不过不管我们是用Canvas的ImageData API处理像素、应用滤镜还是纹理合成都有一个弊端那就是我们必须循环遍历图片上的每个像素点。如果这个图片很大比如它是2000px宽、2000px高我们就需要遍历400万像素这个计算量是相当大的。
因为在前面的例子中,我们生成的都只是静态的图片效果,所以这个计算量的问题还不明显。一旦我们想要利用像素处理,制作出更酷炫的动态效果,这样的计算量注定会成为性能瓶颈。这该怎么办呢?
好在我们还有WebGL这个神器。WebGL通过运行着色器代码来完成图形的绘制和输出。其中片元着色器负责处理像素点的颜色。那接下来我们来说说如何用片元着色器处理像素。
## 片元着色器是怎么处理像素的?
如果想要在片元着色器中处理像素我们需要先将图片的数据信息读取出来交给WebGL程序来处理这样我们就可以在着色器中处理了。
那么如何将图片数据信息读取出来呢在WebGL中我们会使用特殊的一种对象叫做**纹理对象**Texture。我们将纹理对象作为一种特殊格式的变量通过uniform传递给着色器这样就可以在着色器中处理了。
纹理对象包括了整张图片的所有像素点的颜色信息在着色器中我们可以通过纹理坐标来读取对应的具体坐标处像素的颜色信息。纹理坐标是一个变量类型是二维向量x、y的值从0到1。在我们前面的课程里已经见过这个变量就是我们传给顶点着色器的uv属性对应片元着色器中的vUv变量。
因此着色器中是可以加载纹理对象的。具体来说就是我们先通过图片或者Canvas对象来创建纹理对象然后通过uniform变量把它传入着色器。这样我们再通过纹理坐标vUv就可以从加载的纹理对象上获取颜色信息。
### 1. 加载纹理
下面,我就详细说说每一步的具体操作。
**首先是创建纹理对象**。这个步骤比较复杂因为设置不同的参数可以改变我们在Shader中对纹理取色的行为所以其中最复杂的是参数部分。但在这里我们不需要知道太多你先记住我在代码里给出的这几个就够了。其他的如果之后需要用到你再去参考[相关的资料](https://zhuanlan.zhihu.com/p/68894334)就可以了。代码如下所示:
```
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坐标将纹理图片上对应的颜色取出来其他什么也没做所以画布上最终呈现出来的还是原始图片。
<img src="https://static001.geekbang.org/resource/image/d2/36/d22c3ff49fd473b4cb6b52f036542a36.jpg" alt="">
### 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();
```
还记得吗?上一节课我们也实现了一个颜色矩阵,那它们有什么区别呢?区别主要有两个。
首先上一节课的颜色矩阵是一个4**5的矩阵但是因为GLSL语法在数据类型上不能直接支持mat44**4以上的矩阵所以我们要计算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的高性能我们可以实现一些更加复杂的效果比如给图片实现一个粒子化的渐显效果。如下图所示
<img src="https://static001.geekbang.org/resource/image/02/ea/02df04608e76c0920ac9ef1525c42aea.gif" alt="">
这个视觉效果如果在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处取色而是从这个随机偏移的位置取色就能保证取出来的颜色就是一个乱序的色值。这时候图片显示的效果是一片随机的画面
<img src="https://static001.geekbang.org/resource/image/20/3d/20dc4c07e075da18a44975142e79913d.jpg" alt="">
接着我们引入uTime变量用mix函数对偏移后的uv和原始的vUv相对于时间变化进行插值。当初始时间为0的时候取色从uv取当时间超过一个周期的时候取色从vUv取当时间在中间时取值介于uv和vUv之间。
最后我们再把uTime也和透明度关联起来。这样就实现了你上面看到的粒子化的渐显效果。
当然,这个效果做得其实还比较粗糙,因为我们引入的变量比较少,在后续的课程中,我们会一步一步深入,继续实现更加惊艳的效果。在课后,你也可以试着实现其他的效果,然后把你的成果分享出来。
### 4. 实现图像合成
除此之外Fragment Shader还可以引入多纹理让我们可以很方便地实现图像合成。比如说对于在电影场景合成中比较常用的绿幕图片我们就可以使用shader技术把它实时地合成到其他的图像上。
举个例子,假设我们有一只猫的绿幕图片:
举个例子,现在我们有一张带有猫的绿幕图片。
<img src="https://static001.geekbang.org/resource/image/20/cc/20febddf9e1edbb9e7bd349e544f24cc.jpg" alt="" title="带有猫的绿幕图片">
我们要通过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 &lt; 0.5 &amp;&amp; cat.g &gt; 0.6) {
gl_FragColor.rgb = color.rgb;
}
gl_FragColor.a = color.a;
}
```
如上面的代码所示我们可以先通过tCat纹理获取绿幕图片。如果RGB通道中的G通道超过阈值且R通道低于阈值我们就可以接着把猫的图像从纹理中定位出来。然后经过缩放和平移变换等操作我们就能把它放置到画面中适当的位置。
<img src="https://static001.geekbang.org/resource/image/d2/dc/d2d1aacc6cc7c7c6a19c24ea821564dc.jpg" alt="">
## 要点总结
今天我们讨论了边缘模糊和纹理叠加这两种滤镜并且重点学习了用Shader加载纹理和实现滤镜的方法。
首先,我们知道了什么是边缘模糊,边缘模糊很容易实现,只要我们在遍历像素点的时候,同时计算当前像素点到图片中心点的距离,然后根据距离设置透明度,就可以实现边缘模糊的效果。
然后, 我们重点讲了Shader中的纹理叠加滤镜。
要实现这个滤镜我们要先加载纹理获取纹理的颜色。用Shader加载纹理的过程比较复杂但我们可以使用一些封装好的库如gl-renderer来简化纹理的加载。那在获取纹理的颜色的时候我们可以通过texture2D函数读取纹理单元对应的uv坐标处的像素颜色。
加载了纹理之后呢,我们就可以通过纹理结合滤镜函数来处理像素,这就是纹理滤镜的应用场景了。通过纹理滤镜,我们不仅可以实现灰度化图片,还可以图片的粒子化渐显等等更加复杂的效果
除此之外我们还可以使用shader加载多个纹理图片把它们的颜色按照不同的方式进行叠加从而实现图像合成。图像合成虽然在可视化中使用得比较少但它非常适合用来实现一些特殊的视觉效果。
## 小试牛刀
<li>
你可以完善一下片元着色器中的颜色滤镜函数,实现灰度效果以外的效果吗?
</li>
<li>
上节课我们用Canvas2D实现了平滑效果滤镜其实我们也可以用Fragment Shader结合纹理的形式把它实现出来你能做到吗
</li>
<li>
如果我们想让一个图片的某个局部呈现“马赛克”效果,该用什么滤镜?你能把它实现出来吗?
</li>
## 欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程示例代码](https://github.com/akira-cn/graphics/tree/master/pixels-shader)
## 推荐阅读
[Texture的参数设置参考文档](https://zhuanlan.zhihu.com/p/68894334)

View File

@@ -0,0 +1,654 @@
<audio id="audio" title="14 | 如何使用片元着色器进行几何造型?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/3f/4e80a2a0cb52e864f28d6ca9fa0c953f.mp3"></audio>
你好,我是月影。
在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;
}
```
<img src="https://static001.geekbang.org/resource/image/9b/f6/9b33fc3c5b08343114c479574f0484f6.jpeg" alt="">
不仅如此我们还可以在上图的基础上继续做调整。我们可以通过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;
}
```
<img src="https://static001.geekbang.org/resource/image/66/a2/66b0826578525073320c14e6120a8aa2.jpeg" alt="">
事实上,再改用不同的方式,我们还可以生成更多有趣的图案。不过,这里我们就不继续了,因为上面这些做法有点像是灵机一动的小技巧。实际上,我们缺少的并不是小技巧,而是一套统一的方法论。我们希望能够利用它,在着色器里精确地绘制出我们想要的几何图形。
## 如何用片元着色器绘制圆、线段和几何图形
那接下来,我们就通过几个例子,把片元着色器精确绘图的方法论给总结出来。
### 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;
}
```
通过这样的方法,我们最终绘制出了一个模糊的圆,效果如下:
<img src="https://static001.geekbang.org/resource/image/84/58/84239f4aaf8f54cdbe93a65e3bfd2d58.jpeg" alt="">
为什么这个圆是模糊的呢这是因为越靠近圆心距离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;
}
```
<img src="https://static001.geekbang.org/resource/image/8b/36/8b3acfbe004038caba9f9c1a1429ae36.jpeg" alt="">
不过你会发现我们得到的这个圆的边缘很不光滑。这是因为浮点数计算的精度导致的锯齿现象。为了解决这个问题我们用smoothstep代替step。
<img src="https://static001.geekbang.org/resource/image/3f/c8/3f73b8cf75cf0c67cdb7815d0b7051c8.jpeg" alt="">
为什么smoothstep代替step就可以得到比较光滑的圆呢这是因为smoothstep和step类似都是阶梯函数。但是与step的值是直接跳跃的不同smoothstep在step-start和step-end之间有一个平滑过渡的区间。因此用smoothstep绘制的圆边缘就会有一圈颜色过渡就能从视觉上消除锯齿。
<img src="https://static001.geekbang.org/resource/image/52/d7/52c7484a1e18526e2fca815c4083b5d7.jpeg" alt="">
片元着色器绘制的圆,在构建图像的粒子效果中比较常用。比如,我们可以用它来实现图片的渐显渐隐效果。下面是片元着色器中代码,以及我们最终能够实现的效果图。
```
#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;
}
```
<img src="https://static001.geekbang.org/resource/image/a6/9d/a6ffc10e75f217078c68c8dd6a7b4f9d.gif" alt="">
### 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;
}
```
比如,我们利用上面的代码,就能在画布上画出一条斜线。
<img src="https://static001.geekbang.org/resource/image/61/7f/618fc882f894386eccdcf4bdea233e7f.jpeg" alt="">
如果你还不能一眼看出上面的代码为什么能画出一条直线,说明你对于图形学的向量计算思维还没有完全适应。不过别着急,随着我们练习的增多,你会逐渐适应的。下面,我来解释一下这段代码。
这里我们用一个三维向量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) =&gt; {
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();
```
<img src="https://static001.geekbang.org/resource/image/aa/71/aa095666cfb32167aff86d051b929271.gif" alt="">
在上面的例子中我们的直线是经过原点的。那如果我们想让直线经过任意的定点该怎么办我们可以加一个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的时候要分两种情况讨论当点到线段的投影位于线段两个端点中间的时候它就等于点到直线的距离当点到线段的投影在两个端点之外的时候它就等于这个点到最近一个端点的距离。
这么说还是比较抽象我画了一个示意图。你会看到C<sub>1</sub>到线段ab的距离就等于它到线段所在直线的距离C<sub>2</sub>到线段ab的距离是它到a点的距离C<sub>3</sub>到线段的距离是它到b点的距离。那么如何判断究竟是C<sub>1</sub>、C<sub>2</sub>、C<sub>3</sub>中的哪一种情况呢答案是通过C<sub>1</sub>到线段ab的投影来判断。
<img src="https://static001.geekbang.org/resource/image/64/2b/64cb19cd9fabb219db3d185d3a77922b.jpeg" alt="">
所以我们在原本片元着色器代码的基础上抽象出一个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 &gt;= 0.0 &amp;&amp; proj &lt;= 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),就会得到如下效果:
<img src="https://static001.geekbang.org/resource/image/b4/06/b4b5cf71a53c50b95a7e4abbee40d506.gif" alt="">
### 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 &gt;= 0.0 &amp;&amp; proj &lt;= 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 &gt;= 0.0 &amp;&amp; d2 &gt;= 0.0 &amp;&amp; d3 &gt;= 0.0 || d1 &lt;= 0.0 &amp;&amp; d2 &lt;= 0.0 &amp;&amp; d3 &lt;= 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;
}
```
这样,我们就绘制出了一个白色的三角形。
<img src="https://static001.geekbang.org/resource/image/34/9b/34ae3dfdca357d9207ea42e02d1f269b.jpeg" alt="">
实际上,三角形的这种画法还可以推广到任意凸多边形。比如,矩形和正多边形就可以使用同样的方式来绘制。
## 片元着色器绘图方法论:符号距离场渲染
现在,你应该知道这些基本的线段、圆和几何图形该怎么绘制了。那我们能不能从中总结出一套统一的方法论呢?我们发现,前面绘制的图形虽然各不相同,但是它们的绘制步骤都可以总结为以下两步。
**第一步:定义距离**。这里的距离,是一个人为定义的概念。在画圆的时候,它指的是点到圆心的距离;在画直线和线段的时候,它是指点到直线或某条线段的距离;在画几何图形的时候,它是指点到几何图形边的距离。
**第二步:根据距离着色**。首先是用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),我们看到输出的结果变成对称的两条直线了。
<img src="https://static001.geekbang.org/resource/image/dc/c9/dcfb68cb31eae8c8f99dbc663a207dc9.jpeg" alt="">
这是为什么呢因为我们是对距离原直线0.2处的点进行的着色那实际上距离0.2的点有两条线,所以就能绘制出两条直线了。我把它的原理画了一个示意图,你可以看看,其中红线是原直线。
<img src="https://static001.geekbang.org/resource/image/ba/e5/ba296d4490d0a0c699f6ecc739e003e5.jpeg" alt="">
利用这个思路再加上使用乘法和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;
}
```
<img src="https://static001.geekbang.org/resource/image/2a/28/2ac38ebaf3699b4d1edebf4eecc19b28.jpeg" alt="">
利用同样的办法,我们还可以绘制圆环或者三角环或者其他图形的环。因为原理相同,下面我就直接给你展示代码和效果了。
首先是绘制圆环的代码和效果:
```
#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;
}
```
<img src="https://static001.geekbang.org/resource/image/04/49/04b066db1e7b9ea0003df73e64fcbf49.jpeg" alt="">
然后是绘制三角环的代码和效果:
```
#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 &gt;= 0.0 &amp;&amp; proj &lt;= 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 &gt;= 0.0 &amp;&amp; d2 &gt;= 0.0 &amp;&amp; d3 &gt;= 0.0 || d1 &lt;= 0.0 &amp;&amp; d2 &lt;= 0.0 &amp;&amp; d3 &lt;= 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;
}
```
<img src="https://static001.geekbang.org/resource/image/c6/0f/c682ed12d9f148261d8ba076209eb90f.jpeg" alt="">
实际上,上面这种绘制图形和环的方式,在图形渲染中有一个专有的名称叫做**符号距离场渲染**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)来渲染颜色,就会得到的如下的效果。
<img src="https://static001.geekbang.org/resource/image/3e/84/3eaf4531819d34a61e9ea5aacef4cd84.jpeg" alt="">
你能看到这里的每一环两两之间的距离是沿着法线方向从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;
}
```
利用上面的代码,我们对图像进行三角形剪裁,可以实现的效果如下:
<img src="https://static001.geekbang.org/resource/image/87/54/877b37250d0e2da82b3e72fcd83b3754.gif" alt="">
其次,我们可以实现对图像的动态修饰,比如类似下面这种进度条。
```
#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 &gt; 0.0 &amp;&amp; c1 &gt; 0.0 &amp;&amp; c2 &lt; 0.0) {
d = 1.0;
}
if(c0 &lt; 0.0 &amp;&amp; (c1 &gt;= 0.0 || c2 &lt;= 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);
}
```
<img src="https://static001.geekbang.org/resource/image/f0/54/f07b6c187786c59029ab626c5b605254.gif" alt="">
第三我们还可以在一些3D场景中修饰几何体比如像这样给一个球体套一个外壳这个例子的代码我就不贴出来了在后续3D课程中我们再详细来说。
<img src="https://static001.geekbang.org/resource/image/4f/09/4f65c00dd8fa1a58bf7128a987d31109.gif" alt="">
## 要点总结
这一节课我们学习了使用片元着色器进行几何造型的2种常用方法。
首先,用片元着色器可以通过控制局部颜色来绘制图案,比如根据像素坐标来控制颜色变化,然后利用重复绘制的技巧,形成有趣的图案花纹。
其次,我们可以定义并计算像素坐标的距离,然后根据距离来填充颜色,这种方法实际上叫做符号距离场渲染,是着色器造型生成图案的基础方法。通过这种方法我们可以绘制圆、直线、线段、三角形以及其他图形。
使用着色器绘制几何图形是WebGL常用的方式它有许多用途比如可以剪裁图像、显示进度条、实现外壳纹路等等因此在可视化中有许多使用场景。
## 小试牛刀
这一节课我们介绍了圆、直线、线段和三角形的基本画法其他图形也可以用t方法来绘制。试着用同样的思路来绘制正方形、正六角星、椭圆吧
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
## 源码
[本节课完整代码.](https://github.com/akira-cn/graphics/tree/master/shaping-functions)

View File

@@ -0,0 +1,414 @@
<audio id="audio" title="15 | 如何用极坐标系绘制有趣图案?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ce/cb/ce85fcf7400a819dab5de26f7d47fdcb.mp3"></audio>
你好,我是月影。
在前面的课程中,我们一直是使用直角坐标系来绘图的。但在图形学中,除了直角坐标系之外,还有一种比较常用的坐标系就是极坐标系。
[<img src="https://static001.geekbang.org/resource/image/b6/31/b62312e2af6385ffcdb1d3dab4fdd731.jpeg" alt="" title="极坐标示意图">](http://zh.wikipedia.org)
你对极坐标系应该也不陌生它是一个二维坐标系。与二维直角坐标系使用x、y分量表示坐标不同极坐标系使用相对极点的距离以及与x轴正向的夹角来表示点的坐标360°
在图形学中极坐标的应用比较广泛它不仅可以简化一些曲线方程甚至有些曲线只能用极坐标来表示。不过虽然用极坐标可以简化许多曲线方程但最终渲染的时候我们还是需要转换成图形系统默认支持的直角坐标才可以进行绘制。在这种情况下我们就必须要知道直角坐标和极坐标是怎么相互转换的。两个坐标系具体转换比较简单我们可以用两个简单的函数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节课](https://time.geekbang.org/column/article/256827)中为了更方便地绘制曲线我们用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 &lt;= 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, θ) =&gt; {
return [r * Math.cos(θ), r * Math.sin(θ)];
};
const arc = parametric(
t =&gt; 200,
t =&gt; t,
fromPolar,
);
arc(0, Math.PI).draw(ctx);
```
此外,我们还可以添加其他的极坐标参数方程来绘制更多曲线,比如玫瑰线、心形线或者双纽线。因为这些操作都比较简单,我就直接在下面给出代码了。
```
const rose = parametric(
(t, a, k) =&gt; a * Math.cos(k * t),
t =&gt; t,
fromPolar,
);
rose(0, Math.PI, 100, 200, 5).draw(ctx, {strokeStyle: 'blue'});
const heart = parametric(
(t, a) =&gt; a - a * Math.sin(t),
t =&gt; t,
fromPolar,
);
heart(0, 2 * Math.PI, 100, 100).draw(ctx, {strokeStyle: 'red'});
const foliumRight = parametric(
(t, a) =&gt; Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
t =&gt; t,
fromPolar,
);
const foliumLeft = parametric(
(t, a) =&gt; -Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
t =&gt; 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'});
```
最终,我们能够绘制出如下的效果:
<img src="https://static001.geekbang.org/resource/image/47/fa/475905a6708e51yy234c640f292833fa.jpeg" alt="">
总的来说,我们看到,使用极坐标系中参数方程来绘制曲线的方法,其实和我们学过的直角坐标系中参数方程绘制曲线差不多,唯一的区别就是在具体实现的时候,我们需要额外增加一个坐标映射函数,将极坐标转为直角坐标才能完成最终的绘制。
## 如何使用片元着色器与极坐标系绘制图案?
在前面的例子中我们主要还是通过参数方程来绘制曲线用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;
}
```
这样,在画布上绘制出来的结果是三瓣玫瑰线:
<img src="https://static001.geekbang.org/resource/image/f7/8a/f73a97f5f742dd5d9c2c53b8ecf5908a.jpeg" alt="">
可能你还是会有疑问为什么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。
<img src="https://static001.geekbang.org/resource/image/72/a9/7244ff9e7d36b8dd5ayy04e42430a5a9.jpeg" alt="">
因此smoothstep(-0.01, 0.01, d) 能够将 d &gt;= 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(() =&gt; {
renderer.uniforms.u_k += 2;
}, 200);
```
<img src="https://static001.geekbang.org/resource/image/0f/18/0fa365713c9676219e72cd55073f7318.gif" alt="">
类似的图案还有花瓣线:
```
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的时候我们可以得到如下图案
<img src="https://static001.geekbang.org/resource/image/e5/47/e51fc1ca89f103b3f949477424f18047.jpeg" alt="">
有趣的是它和玫瑰线不一样u_k的取值不一定要是整数。这让它能绘制出来的图形更加丰富比如说我们可以取u_k=1.3,这时得到的图案就像是一个横放的苹果。
<img src="https://static001.geekbang.org/resource/image/ff/96/ff98434df974b610078f76aab6c96896.jpeg" alt="">
在此基础上我们还可以再添加几个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.7u_scale=0.5u_offset=0.2时,就能得到一个横置的葫芦图案。
<img src="https://static001.geekbang.org/resource/image/5b/74/5b303d4e6e7afd2f2bb61f10e9717574.jpeg" alt="">
如果我们继续修改 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 的计算方程时,我们可以绘制出花苞图案:
<img src="https://static001.geekbang.org/resource/image/8e/e9/8e87d4e5c76a06645860819474a25fe9.jpeg" alt="">
方法已经知道了你可以在课后结合三角函数、abs、smoothstep来尝试绘制一些更有趣的图案。如果有什么特别好玩的图案你也可以分享出来。
## 极坐标系如何实现角向渐变?
除了绘制有趣的图案之外,极坐标的另一个应用是**角向渐变**Conic Gradients。那角向渐变是什么呢如果你对CSS比较熟悉一定知道角向渐变就是以图形中心为轴顺时针地实现渐变效果。而且新的 [CSS Image Values and Replaced Content](https://www.w3.org/TR/css-images-4/#conic-gradients) 标准 level4 已经添加了角向渐变,我们可以使用它来创建一个基于极坐标的颜色渐变,代码如下:
```
div.conic {
width: 150px;
height: 150px;
border-radius: 50%;
background: conic-gradient(red 0%, green 45%, blue);
}
```
<img src="https://static001.geekbang.org/resource/image/6b/3e/6bdeda39bcbff4b2269d641df8f9d33e.jpeg" alt="">
我们可以通过角向渐变创建一个颜色由角度过渡的元素。在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 &lt; 0.0) st.y += 6.28;
// 计算p的值也就是相对角度p取值0到1
float p = st.y / 6.28;
if(p &lt; 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%之间,我们让颜色从绿色过渡到蓝色。这样,我们最终就会得到如下效果:
<img src="https://static001.geekbang.org/resource/image/67/19/678161af2d8ff7ee9029bc9116cc0219.jpeg" alt="">
这个效果与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 &lt; 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;
}
```
最终的效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/39/bf/3998fd1107b9c234a28eeee1bb11fabf.gif" alt="">
## 圆柱坐标与球坐标
最后我还想和你说说极坐标和圆柱坐标系以及球坐标系之间的关系。我们知道极坐标系是二维坐标系如果我们将极坐标系延z轴扩展可以得到圆柱坐标系。圆柱坐标系是一种三维坐标系可以用来绘制一些三维曲线比如螺旋线、圆内螺旋线、费马曲线等等。
[<img src="https://static001.geekbang.org/resource/image/1d/10/1d697208453d1a9557a659b1f9c5db10.jpeg" alt="" title="圆柱坐标系">](https://zh.wikipedia.org/)
因为极坐标系可以和直角坐标系相互转换,所以直角坐标系和圆柱坐标系也可以相互转换,公式如下:
<img src="https://static001.geekbang.org/resource/image/86/ef/86a5b3052493841f8ec648eb260b17ef.jpg" alt="">
从上面的公式中你会发现我们只转换了x、y的坐标因为它们是极坐标而z的坐标因为本身就是直角坐标不用转换。因此圆柱坐标系又被称为**半极坐标系。**
在此基础上,我们还可以进一步将圆柱坐标系转为球坐标系。
[<img src="https://static001.geekbang.org/resource/image/a3/c1/a3ba1a1bb31090ffa90887907ee65ec1.jpeg" alt="" title="球坐标系">](https://zh.wikipedia.org)
同样地,圆柱坐标系也可以和球坐标系相互转换,公式如下:
<img src="https://static001.geekbang.org/resource/image/82/a9/8262a74b379f8433f1326e851ed579a9.jpg" alt="">
球坐标系在三维图形绘制、球面定位、碰撞检测等等可视化实现时都很有用,在后续的课程中,我们会有机会用到球坐标系,在这里你需要先记住它的转换公式。
## 要点总结
这一节课,我们学习了一个新的坐标系统也就是极坐标系,并且理解了直角坐标系与极坐标系的相互转换。
极坐标系是使用相对极点的距离以及与x轴正向的夹角来表示点的坐标。极坐标系想要转换为直角坐标系需要用到fromPolar函数反过来需要用到toPolar函数。
那在具体使用极坐标来绘制曲线的时候有两种渲染方式。第一种是用Cavans渲染这时候我们可以用到之前学过的parametric高阶函数将极坐标参数方程和坐标映射函数fromPolar传入得到绘制曲线的函数再用它来执行绘制。这样极坐标系就能实现直角坐标系不太好描述的曲线了比如玫瑰线、心形线等等。
第二种是使用shader渲染一般的方法是先将像素坐标转换为极坐标然后使用极坐标构建距离场并着色。它能实现更多复杂的图案。
除了绘图使用极坐标还可以实现角向渐变和HSV色轮。角向渐变通常可以用在构建饼图而HSV色轮一般用在颜色可视化和择色交互等场合里。
此外,你还需要了解圆柱坐标、球坐标与直角坐标系的相互转换。在后续课程里,我们会使用圆柱坐标或球坐标来处理三维图形,到时候它们会非常有用。
## 小试牛刀
<li>
用极坐标绘制小图案时,我们绘制了苹果和葫芦的图案,但它们是横置的。你可以试着修改它们,让它们的方向变为正向吗?具体怎么做呢?
</li>
<li>
在角向渐变的例子中CSS角向渐变是与Y轴的夹角而使用着色器绘制的版本是与X轴的夹角。那如果要让着色器绘制版本的效果与CSS角向渐变效果完全一致我们该怎么做呢
</li>
<li>
我们已经学过了随机数、距离场以及极坐标,你是不是可以利用它们绘制出一个画布,并且呈现随机的剪纸图案,类似的效果如下所示。
</li>
<img src="https://static001.geekbang.org/resource/image/d8/8e/d899fd39482d17ff720c3f86d5d5858e.jpeg" alt="">
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
## 源码
[parametric-shader](https://github.com/akira-cn/graphics/tree/master/parametric-polar)<br>
[ploar-shader](https://github.com/akira-cn/graphics/tree/master/polar-shader)

View File

@@ -0,0 +1,493 @@
<audio id="audio" title="16 | 如何使用噪声生成复杂的纹理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/6e/5c1593fa357775354d627732a643fe6e.mp3"></audio>
你好,我是月影。
在[第11节课](https://time.geekbang.org/column/article/262330)中,我们使用随机技巧生成噪点、迷宫等复杂图案。它们的作用都是表达数据和增强视觉效果。要想在可视化视觉呈现中实现更加酷炫的视觉效果,我们经常需要生成能够模拟大自然的、丰富而复杂的纹理图案。
那么这节课,我们就继续来讨论,如何使用随机技巧来生成更加复杂的纹理图案。
## 什么是噪声?
我们先来回忆一下随机效果是怎么生成的。在第11节课中我们使用一个离散的二维伪随机函数随机生成了一片带有噪点的图案。代码和最终效果如下
```
float random (vec2 st) {
return fract(sin(dot(st.xy,
vec2(12.9898,78.233)))*
43758.5453123);
}
```
<img src="https://static001.geekbang.org/resource/image/3a/8e/3a539a23b70f8ca34a3c126139035d8e.jpeg" alt="">
然后,我们用取整的技巧,将这个图案局部放大,就呈现出了如下的方格状图案:
<img src="https://static001.geekbang.org/resource/image/40/2a/4082865db53e073b31520b9cyy90642a.jpeg" alt="">
在真实的自然界中,这种离散的随机是存在的,比如鸟雀随机地鸣叫,蝉鸣随机地响起再停止,雨滴随机地落在某个位置等等。但随机和连续并存是更常见的情况,比如山脉的走向是随机的,山峰之间的高度又是连续,类似的还有天上的云朵、水流的波纹、被侵蚀的土地等等。
<img src="https://static001.geekbang.org/resource/image/04/c0/0441979299f96d57f2a6c87d0c9f08c0.jpeg" alt="">
因此,要模拟这些真实自然的图形,我们就需要把随机和连续结合起来,这样就形成了**噪声**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段不连续的线段。
<img src="https://static001.geekbang.org/resource/image/2c/2c/2c89840e5d6e10ed22188bdc827b762c.jpeg" alt="">
然后,我们用 mix(random(i), random(i + 1.0), f); 替换 random(i)你可以将上面代码第18行注释掉将第19行注释去掉那么这些线段的首尾就会连起来也就是说我们将得到一段连续的折线。
<img src="https://static001.geekbang.org/resource/image/ae/d4/aeccd8853f623190c30ed74759dfafd4.jpeg" alt="">
不过,我们得到的折线虽然连续,但因为这个函数在端点处不可导,所以它不平滑。因此,我们可以改用 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。这样我们就得到一条连续并且平滑的曲线了。
<img src="https://static001.geekbang.org/resource/image/f4/4a/f4a05f47b8520ec2bf0dff35d561244a.jpeg" alt="">
这也就是我们想要的噪声函数了。
但是这个函数是一维的如果要使用二维的我们还可以把它扩展到二维。这个时候我们就必须要知道二维噪声和一维噪声之间的区别。很明显一维噪声是对两点进行插值的而二维噪声需要对平面画布上方形区域的四个顶点分别从x、y方向进行两次插值。
[<img src="https://static001.geekbang.org/resource/image/98/ef/9802a1a82dd52d108c1d5yy449cefbef.jpeg" alt="" title="图片来源The Book of Shaders"> ](https://thebookofshaders.com/11/?lan=ch)
具体怎么做呢我们可以把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;
}
```
通过上面的代码,我们就可以得到下面这个看起来比较模糊的噪声图案。
<img src="https://static001.geekbang.org/resource/image/fb/b3/fb617c991c7abfa6b61fcb298ce4a8b3.jpeg" alt="" title="模糊的噪声图案">
## 噪声的应用
那你可能想问了,我们上面实现的一维噪声波形和二维的模糊噪声图案都比较简单,那它们到底是怎么模拟自然界中的现象,又该怎么实现有趣的视觉效果呢?
接下来我们先结合上面得到的噪声函数来讲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;
}
```
<img src="https://static001.geekbang.org/resource/image/a6/18/a6325bb4912957b864390789c5468118.gif" alt="">
我们也可以使用不同的距离场构造方式,加上旋转噪声,构造出类似于木头的条纹。代码如下:
```
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;
}
```
<img src="https://static001.geekbang.org/resource/image/e8/fe/e8237529e1aa3795e89ea3a9366a56fe.jpeg" alt="">
这两个应用的实现代码非常简单,你直接看代码就能理解。我更希望的是,你能通过我给出的代码,来理解这种噪声结合距离场的实现思路。
### 梯度噪声
我们前面说的噪声算法,它的原理是对离散的随机值进行插值,因此它又被称为**插值噪声**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;
}
```
梯度噪声可以平滑到什么程度呢?我在下面给出了两种噪声算法生成的图像,你可以明显得看出对比。第一个图是插值噪声的效果,黑白色之间过渡不够平滑,还有明显的色块感,第二个图是梯度噪声的效果,黑白的过渡就明显平滑多了,不再呈现块状。
<img src="https://static001.geekbang.org/resource/image/f5/2d/f553f0eb5621f795a134e6d85478e52d.png" alt="" title="插值噪声与梯度噪声对比">
因此,梯度噪声在二维空间中的应用更广泛,许多有趣的模拟自然界特效的视觉实现都采用了梯度噪声。你可以研究一下[Shadertoy.com](https://www.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 &lt; 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;
}
```
<img src="https://static001.geekbang.org/resource/image/3f/f2/3f1719cb63f5e6d0c0d182a15505e5f2.gif" alt="" title="使用云雾效果生成的图案,非常像是飞机航拍">
### Simplex Noise
接下来我还想给你讲一种更新的噪声算法它是Ken Perlin在2001 年的 Siggraph会议上展示的Simplex Noise算法。
相比于前面的噪声算法Simplex Noise算法有更低的计算复杂度和更少的乘法运算并且可以用更少的计算量达到更高的维度而且它制造出的噪声非常自然。
Simplex Noise与插值噪声以及梯度噪声的不同之处在于它不是对四边形进行插值而是对三角网格进行插值。与四边形插值相比三角网格插值需要计算的点更少了这样自然大大降低了计算量从而提升了渲染性能。
Simplex Noise具体的实现思路非常精巧和复杂其中包含的数学技巧比较高深这里我就不详细来讲了如果你有兴趣学习可以参考[Book of Shaders的文章](https://thebookofshaders.com/11/?lan=ch)来学习。
尽管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 &gt; 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;
}
```
渲染效果如下图:
<img src="https://static001.geekbang.org/resource/image/39/0a/3984a318acc90ccce0dcaf65aaf0a60a.jpeg" alt="">
Simplex Noise可以实现出令人惊叹的效果在[Shadertoy.com](https://www.shadertoy.com/)平台上经常有大神分享他们创作的神奇效果。比如,[这个](https://www.shadertoy.com/view/MdSXzz)像某种溶洞的岩壁效果,就有一种大自然鬼斧神工的韵味在。
<img src="https://static001.geekbang.org/resource/image/bb/48/bbb7f35c9a0f4b639825074764025a48.gif" alt="">
再比如,[这种像电影大片中才有的效果](https://www.shadertoy.com/view/Ms2SD1)你很难想象这并不是视频甚至不是图片只不过是我们用数学公式在Shader中计算并绘制出来的图案而已。
<img src="https://static001.geekbang.org/resource/image/0c/cc/0c209d3f0f65d45457420f74c057d2cc.gif" alt="">
### 网格噪声
最后,我们来讲讲网格噪声。前面我们已经使用过大量网格化的技术,我想你也应该比较熟悉了。那什么是网格噪声呢?它就是将噪声与网格结合使用的一种纹理生成技术。下面,让我们通过一个生成动态生物细胞的例子,来详细理解一下如何使用网格噪声。
首先我们用网格技术将画布分为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;
}
```
通过上面的代码,我们最终能得到如下的效果:
<img src="https://static001.geekbang.org/resource/image/dc/ed/dc089df759336b3636f2a3cf7bfa71ed.jpeg" alt="">
我们可以看到这里的每个网格是独立的并且界限分明。那如果我们想让它们的边界过渡更圆滑该怎么办呢我们可以在原来的代码上做改变具体来说就是不仅计算特征点到当前网格的距离还要计算它到周围相邻的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 &lt;= 1; i++) {
for(int j = -1; j &lt;= 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个网格所以就用了两重循环来实现。
<img src="https://static001.geekbang.org/resource/image/e8/c6/e8ed59accc7575f2fa22dc0a3e580fc6.jpeg" alt="" title="计算像素坐标到九个特征点的最短距离">
<img src="https://static001.geekbang.org/resource/image/27/bb/277a3bb605dc035edb2ae43db1a679bb.jpeg" alt="">
然后我们加上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 &lt;= 1; i++) {
for(int j = -1; j &lt;= 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;
}
```
这样,最终绘制的效果如下,它就有点像是运动的生物细胞。
<img src="https://static001.geekbang.org/resource/image/b6/c6/b635854be3e0a9336906b02e46cdb3c6.gif" alt="">
网格噪声是一种目前被广泛应用的程序化纹理技术,用来生成随机网格类的视觉效果,可以用来模拟物体表面的晶格、晶体生长、细胞、微生物等等有趣的效果。
[<img src="https://static001.geekbang.org/resource/image/63/c7/6396d5c7f143410352cb04da2a3cdbc7.jpg" alt="" title="网格噪声模拟物体表面晶格图片来源于The Book of Shaders">](https://thebookofshaders.com/12/?lan=ch)
## 要点总结
总的来说,这节课我给你讲的技术,实际上是一种复杂的程序化纹理生成技术。所谓程序化纹理生成技术,就是用程序来生成物体表面的图案。我们在这些图案中引入类似于自然界中的随机性,就可以模拟出自然的、丰富多采的以及包含真实细节的纹理图案。
这其中最有代表性的就是噪声了噪声就是随机性与连续性结合而成的。噪声是自然界中普遍存在的自然规律。模拟噪声的基本思路是对离散的随机数进行平滑处理对随机数进行平滑处理有不同的数学技巧所以有插值噪声、梯度噪声、Simplex Noise等等不同的噪声算法。它们各有特点我们可以根据不同的情况来选择怎么使用。
这一节课的内容偏向于技巧性,要想掌握好,我建议你多动手实践。我推荐给你一个非常不错的平台,[Shadertoy.com](https://www.shadertoy.com/) 。它是一个非常优秀的创作和分享着色器效果的平台,你可以在上面学习到很多优秀的案例,然后通过代码来理解创作者的创意和思路,巩固今天所学的知识。
## 小试牛刀
你能试着写出一个Shader来实现我在下面给出的网格噪声效果吗欢迎你把它分享出来。
<img src="https://static001.geekbang.org/resource/image/d0/a0/d098bfc78426b56bf83efd5ddae6ffa0.gif" alt="">
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
## 源码
完整示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/noise)
## 推荐阅读
[1] [Shadertoy](https://www.shadertoy.com/)<br>
[2] [The Book of Shaders](https://thebookofshaders.com/11/?lan=ch)

View File

@@ -0,0 +1,654 @@
<audio id="audio" title="17 | 如何使用后期处理通道增强图像效果?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/23/169424ecc77cb01d8e70cc3108829e23.mp3"></audio>
你好,我是月影。
前面几节课,我们学习了利用向量和矩阵公式,来处理像素和生成纹理的技巧,但是这些技巧都有一定的局限性:每个像素是彼此独立的,不能共享信息。
为什么这么说呢因为GPU是并行渲染的所以在着色器的执行中每个像素的着色都是同时进行的。这样一来我们就不能获得某一个像素坐标周围坐标点的颜色信息也不能获得要渲染图像的全局信息。
这会导致什么问题呢?如果我们要实现与周围像素点联动的效果,比如给生成的纹理添加平滑效果滤镜,就不能直接通过着色器的运算来实现了。
因此在WebGL中像这样不能直接通过着色器运算来实现的效果我们需要使用其他的办法来实现其中一种办法就是使用**后期处理通道**。所谓后期处理通道是指将渲染出来的图像作为纹理输入给新着色器处理是一种二次加工的手段。这么一来虽然我们不能从当前渲染中获取周围的像素信息却可以从纹理中获取任意uv坐标下的像素信息也就相当于可以获取任意位置的像素信息了。
使用后期处理通道的一般过程是我们先正常地将数据送入缓冲区然后执行WebGLProgram。只不过在执行了WebGLProgram之后我们要将输出的结果再作为纹理送入另一个WebGLProgram进行处理这个过程可以进行一次也可以循环多次。最后经过两次WebGLProgram处理之后我们再输出结果。
<img src="https://static001.geekbang.org/resource/image/a9/75/a9b5a5f90e2c3e465a0a6d2b7070ef75.jpg" alt="" title="后期通道处理的一般过程示意图">
你可以先仔细看看这张流程总结图加深一下印象。接下来我会结合这个过程说说怎么用后期处理通道来实现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 &gt;= 0.0 &amp;&amp; proj &lt;= 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 &gt;= 0.0 &amp;&amp; d2 &gt;= 0.0 &amp;&amp; d3 &gt;= 0.0 || d1 &lt;= 0.0 &amp;&amp; d2 &lt;= 0.0 &amp;&amp; d3 &lt;= 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;
}
```
这个着色器绘制出的效果如下图:
<img src="https://static001.geekbang.org/resource/image/bc/5b/bce2b0fbe3954c9a690a46f422de4f5b.jpeg" alt="">
接着就是重点了,我们要使用后期处理通道对它进行高斯模糊。
首先我们需要准备另一个着色器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 &lt; 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滤镜给三角形图案加上模糊的效果了。渲染结果如下
<img src="https://static001.geekbang.org/resource/image/0d/91/0d14fea4328158fe9d32f09181a22f91.jpeg" alt="">
## 如何用后期处理通道实现辉光效果?
在上面这个例子中,我们是对所有元素进行高斯模糊的。那除此之外,我们还可以对特定元素进行高斯模糊。在可视化和游戏开发中,就常用这种技巧来实现元素的“辉光”效果。比如,下面这张图就是用辉光效果实现的浩瀚宇宙背景。
[<img src="https://static001.geekbang.org/resource/image/e9/4a/e9b5daeefdde2111cb955d2b91986b4a.jpeg" alt="" title="全局辉光效果图片来源于知乎H光大小姐">](https://zhuanlan.zhihu.com/p/44131797)
那类似这样的辉光效果该怎么实现呢我们可以在前面添加高斯模糊例子的基础上进行修改。首先我们给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 &lt; 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**](https://zh.wikipedia.org/wiki/%E8%89%B2%E8%B0%83%E6%98%A0%E5%B0%84)(色调映射)的方法。这个方法就比较复杂了,在这里,你只要知道它可以将对比度过大的图像色调映射到合理的范围内就可以了,其他的内容你可以在课后看一下我给出的参考链接。
这个着色器的代码如下:
```
#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) &gt; 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();
```
这样渲染之后,我就能让三角形图案中几个比较亮的三角形,产生一种微微发光的效果。渲染效果如下:
<img src="https://static001.geekbang.org/resource/image/69/c5/6905c7b0ff6b8b8d903e0eaeefcbfbc5.jpeg" alt="" title="局部辉光效果示意图">
这样,我们就实现了最终的局部辉光效果。实现它的关键,就是在高斯模糊原理的基础上,将局部高斯模糊的图像与原始图像叠加。
## 如何用后期处理通道实现烟雾效果?
除了模糊和辉光效果之外后期处理通道还经常用来实现烟雾效果。接下来我们就实现一个小圆的烟雾效果。具体的实现过程主要分为两步第一步和前面两个例子一样我们通过创建一个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;
}
```
<img src="https://static001.geekbang.org/resource/image/72/77/72d1816d29d1401636e0099016f80477.jpeg" alt="">
接着我们修改一下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 &lt;= 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 &lt;= 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;
}
```
<img src="https://static001.geekbang.org/resource/image/ec/44/ec596ee38eabbbyy06061fe638a3df44.gif" alt="">
不过,由于纹理采样精度的问题,我们得到的上升圆还会有一个扩散的效果。不过这没有关系,它不影响我们接下来要实现的烟雾效果。
接下来我们需要构建一个烟雾的扩散模型也就是以某个像素位置以及周边像素的纹理颜色来计算新的颜色值。为了方便你理解我就以一个5*5的画布为例来详细说说。假设这个画布只有中心五个位置的颜色是纯白1.0),周围都是黑色,如下图所示。
<img src="https://static001.geekbang.org/resource/image/8e/6b/8ed93106a13428492bd5bc2eyy8d676b.jpeg" alt="">
在这个扩散模型中每个格子到下一时刻的颜色变化量等于它周围四个格子的颜色值之和减去它自身颜色值的4倍乘以扩散系数。
假设扩散系数是常量0.1,那么第一轮每一格的颜色值我在表格上标出来了,如下图所示。
<img src="https://static001.geekbang.org/resource/image/6f/44/6f46f3354b470d217110764cfc3f0344.jpg" alt="">
可以看到,上图中有三种颜色的格子,分别是红色、蓝色和绿色。下面,我们直接来看颜色值的计算过程。
首先是中间红色的那个格子。因为它四周的格子颜色都是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 &lt;= 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;
}
```
<img src="https://static001.geekbang.org/resource/image/81/27/816c8798dbe1bd0999c225fa068d8e27.gif" alt="">
你会发现,这个效果还不是特别真实。那为了达到更真实的烟雾效果,我们还可以在扩散函数上增加一些噪声,代码如下:
```
void main() {
vec3 smoke = vec3(0);
if(uTime &lt;= 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;
}
```
这样,我们最终实现的效果看起来就会更真实一些:
<img src="https://static001.geekbang.org/resource/image/aa/1c/aa617ae27b63fc4687279a17298a721c.gif" alt="">
## 要点总结
今天我们学习了怎么在WebGL中使用后期处理通道来增强图像的视觉效果。我们讲了两个比较常用的效果一个是Blur滤镜以及基于它实现辉光效果,另一个是烟雾效果。
那后期处理通道实现这些效果的核心原理其实都是一样的都是把第一次渲染后的内容输出到帧缓冲对象FBO中然后把这个对象的内容作为纹理图片再进行下一次渲染这个渲染的过程可以重复若干次。最后我们再把结果输出到屏幕上就可以了。
到这里,视觉基础篇的内容,我们就全部讲完了。在这个模块里,我们围绕处理图像的细节,系统地学习了怎么表示颜色,怎么生成重复图案,怎么构造和使用滤镜处理图像,怎么进行纹理造型,还有怎么使用不同的坐标系绘图,怎么使用噪声和网格噪声生成复杂纹理、以及今天学习的怎么使用后期处理通道增强图像。我把核心的内容总结成了一张脑图,你可以借助它,来复习巩固这一模块的内容,查缺补漏。
<img src="https://static001.geekbang.org/resource/image/0b/48/0bcbeb9b391a1b7392b69f9ceaa66548.jpg" alt="">
## 小试牛刀
第一题,今天,我们实现的烟雾扩散效果还不够完善,也不够有趣,你能试着改进它,让它变得更有趣吗?你可以参考我给出的两个建议,也可以试试其他的效果。
1.给定一个向量,表示风向和风速,让烟雾随着这个风扩散,这个风可以随着时间慢慢变化(你可以使用前面学过的噪声来实现风的变化),看看能够做出什么效果。
2.尝试让烟雾跟随着鼠标移动轨迹扩散,达到类似下面的效果。
<img src="https://static001.geekbang.org/resource/image/f1/89/f180f7ca5c2yy5a9304b504893cf8289.gif" alt="">
如果你觉得难以实现,这里有[一篇文章](https://gamedevelopment.tutsplus.com/tutorials/how-to-write-a-smoke-shader--cms-25587)详细讲了烟雾生成的方法,你可以仔细研究一下。
第二题实际上后期处理通道可不止是实现Blur滤镜、辉光或者烟雾效果那么简单它还可以实现很多不同的功能。比如SpriteJS官网上那种类似于[探照灯]((https://spritejs.org/demo/#/shader_and_pass/pass))的效果就是用后期处理通道实现的。你可以用gl-renderer来实现这类效果吗
<img src="https://static001.geekbang.org/resource/image/5d/14/5df356fdb628efe1173089e90238e814.gif" alt="">
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
## 源码
完整的示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/pass)
## 推荐阅读
[1] [原始FBO对象的创建和使用方法](https://blog.csdn.net/xufeng0991/article/details/76736971)
[2] [How to Write a Smoke Shader](https://gamedevelopment.tutsplus.com/tutorials/how-to-write-a-smoke-shader--cms-25587)

View File

@@ -0,0 +1,433 @@
<audio id="audio" title="18 | 如何生成简单动画让图形动起来?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e5/f0/e5afca9389433f61e5d12709ebfe23f0.mp3"></audio>
你好,我是月影。
前面我们用了3个模块的时间学习了大量的图形学和数学知识是不是让你的脑袋有一点昏沉没关系你只是需要一点时间来消化这些知识而已。我能给你的建议就是多思考、多练习有了时间的积累你一定可以掌握这些基础知识和思维方法。
从这一节课开始我们要学习一个非常有意思的新模块那就是动画和3D绘图。对于可视化展现来说动画和3D都是用来强化数据表达吸引用户的重要技术手段。它们往往比二维平面图形能够表达更复杂的数据实现更吸引人的视觉效果。
那今天,我们先来聊聊动画的实现。实际上,我们之前也实现了不少动态效果,但你可能还是不知道怎么去实现动画。接下来,我们就来系统地梳理一下动画实现的标准方法。
## 动画的三种形式
什么是动画呢?简单来说,动画就是将许多帧静止的画面以固定的速率连续播放出来。一般来说,动画有三种形式,分别是固定帧动画、增量动画和时序动画。
第一种形式是我们预先准备好要播放的静态图像,然后将这些图依次播放,所以它叫做**固定帧动画**。**增量动画**是在动态绘制图像的过程中,我们修改每一帧中某个或某几个属性的值,给它们一定的增量。第三种形式是在动态绘制图像的过程中,我们根据时间和动画函数计算每一帧中的关键属性值,然后更新这些属性,所以它叫做**时序动画**。
这么说还是比较抽象下面我就以HTML/CSS为例来带你熟悉这三种动画的基本形式。为什么选HTML/CSS呢因为一般来说HTML/CSS、SVG和Canvas2D实现动画的方式大同小异所以我就直接选择你最熟悉的HTML/CSS了。而WebGL实现动画的方式和其他三种图形系统都有差别所以我会在下一节课单独来说。
### 1. 实现固定帧动画
首先,我们来说说如何实现固定帧动画。
结合固定帧动画的定义我们实现它的第一步就是为每一帧准备一张静态图像。比如说我们要实现一个循环播放3帧的动画就要准备3个如下的图像。
<img src="https://static001.geekbang.org/resource/image/80/0e/80532862be3yy41356172c40b547f30e.jpeg" alt="" title="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;}
}
```
<img src="https://static001.geekbang.org/resource/image/e5/8a/e5cfe9afc454013c3913bfbb03b9548a.gif" alt="" title="动态的小鸟">
虽然固定帧动画实现起来非常简单,但它不适合生成需要动态绘制的图像,更适合在游戏等应用场景中,生成由美术提供现成图片的动画帧图像。而对于动态绘制的图像,也就是非固定帧动画,我们通常会使用另外两种方式。
### 2. 实现增量动画
我们先来说比较简单的增量动画即每帧给属性一个增量。怎么理解呢我举个简单的例子我们可以创建一个蓝色的方块然后给这个方块的每一帧增加一个rotate角度。这样就能实现蓝色方块旋转的动画。具体的代码和效果如下所示。
```
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;Document&lt;/title&gt;
&lt;style&gt;
.block {
width: 100px;
height: 100px;
top: 100px;
left: 100px;
transform-origin: 50% 50%;
position: absolute;
background: blue;
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class=&quot;block&quot;&gt;&lt;/div&gt;
&lt;script&gt;
const block = document.querySelector('.block');
let rotation = 0;
requestAnimationFrame(function update() {
block.style.transform = `rotate(${rotation++}deg)`;
requestAnimationFrame(update);
});
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
<img src="https://static001.geekbang.org/resource/image/fe/41/fe5fd08150d43ff6305042b6ea2ba041.gif" alt="" title="旋转的蓝色方块">
在上面的例子中我们重点关注第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 &gt;= 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) =&gt; {
function next() {
if(update({target, frameIndex, timing}) !== false &amp;&amp; !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度。代码和效果如下所示。
```
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;Document&lt;/title&gt;
&lt;style&gt;
.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;}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class=&quot;container&quot;&gt;
&lt;div class=&quot;block&quot;&gt;&lt;/div&gt;
&lt;div class=&quot;block&quot;&gt;&lt;/div&gt;
&lt;div class=&quot;block&quot;&gt;&lt;/div&gt;
&lt;div class=&quot;block&quot;&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;script type=&quot;module&quot;&gt;
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}) =&gt; {
target.style.transform = `rotate(${timing.p * 360}deg)`;
});
}
}());
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
<img src="https://static001.geekbang.org/resource/image/d2/81/d28f5b8c8cdf8f20dc20566ab45f7181.gif" alt="" title="顺序旋转的四个方块">
## 插值与缓动函数
我们前面说过,时序动画的好处就在于,它能更容易地控制动画的细节。那针对我们总结出的这个标准的动画模型,它又如何控制动画细节呢?
假设,我们已知元素的起始状态、结束状态和运动周期。如果想要让它进行不规则运动,我们可以使用插值的方式来控制每一帧的展现。比如说,我们可以先实现一个匀速运动的方块,再通过插值与缓动函数来实现变速运动。
首先我们用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', () =&gt; {
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) =&gt; {
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时刻的位移和加速度的计算公式如下
<img src="https://static001.geekbang.org/resource/image/fb/f3/fb1502fd6d2bca1db9921a5847409cf3.jpeg" alt="" title="匀加速运动的计算公式">
所以我们把p映射为p的平方。
还是同样的情况下如果物体在做匀减速运动那么它在t时刻的位移和加速度的计算公式如下
<img src="https://static001.geekbang.org/resource/image/5f/d2/5f9726b346e444775080ac98b8e93dd2.jpeg" alt="" title="匀变速运动的计算公式">
所以我们把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 =&gt; 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 &gt;= 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 =&gt; p ** 2});
document.addEventListener('click', () =&gt; {
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) =&gt; {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
```
## 贝塞尔曲线缓动
现在我们已经缓动函数的应用了。缓动函数有很多种其中比较常用的是贝塞尔曲线缓动Bezier-easing准确地说是三次贝塞尔曲线缓动函数。接下来我们就来一起来实现一个简单的贝塞尔曲线缓动。
我们先来复习一下三次贝塞尔曲线的参数方程:
<img src="https://static001.geekbang.org/resource/image/50/6e/50dea3d53d0d81a49b6a3cb982629e6e.jpg" alt="">
对于贝塞尔曲线图形来说t是参数P是坐标。而贝塞尔曲线缓动函数则是把Px作为时间参数p把Py作为p的映射。这样我们就知道了参数方程和缓动函数之间映射关系了。
[<img src="https://static001.geekbang.org/resource/image/f7/0b/f764285ab9554604937917e38f7c440b.jpg" alt="" title="贝塞尔缓动函数图盘来源React.js">](https://react.rocks/example/bezier-easing-editor)
那要想把三次贝塞尔曲线参数方程变换成贝塞尔曲线缓动函数,我们可以使用一种数学方法,叫做[**牛顿迭代法**](https://baike.baidu.com/item/%E7%89%9B%E9%A1%BF%E8%BF%AD%E4%BB%A3%E6%B3%95/10887580?fr=aladdin)Newtons method。因为这个方法比较复杂所以我就不展开细说了。
我们可以使用现成的JavaScript库[bezier-easing](https://github.com/gre/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', () =&gt; {
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) =&gt; {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
```
这样,我们能得到如下的效果:
<img src="https://static001.geekbang.org/resource/image/a8/18/a81289f987f354f7bdd1e983c9472418.gif" alt="">
实际上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](https://easings.net/)这个网站里看一看,然后尝试利用里面提供的缓动函数,来修改我们例子代码中的效果,看看动画过程有什么不同。
## 要点总结
这节课,我们讲了动画的三种形式和实现它们的基本方法,并且我们重点讨论了由时序动画衍生的标准动画模型,以及在此基础上,利用线性插值和缓动函数来控制更多动画细节。
首先,我们来回顾一下这三种形式的实现方法和各自的特点:
- 第一种,固定帧动画。它实现起来最简单,只需要我们为每一帧准备一张图片,然后循环播放就可以了。
- 第二种,增量动画。虽然在实现的时候,我们需要在每帧给元素的相关属性增加一定的量,但也很好操作,就是不好精确控制动画细节。
- 第三种是使用时间和动画函数来描述的动画,也叫做时序动画。这种方法能够非常精确地控制动画的细节,所以它能实现的动画效果更丰富,应用最广泛。
然后为了更方便使用我们根据时序动画定义了标准动画模型实现了Animator类。基于此我们就可以使用线性插值来实现动画的匀速运动通过缓动函数来改变动画的运动速度。
在动画的实现中比较常用贝塞尔曲线缓动函数。它是通过对贝塞尔曲线方程进行牛顿迭代求出我们可以使用bezier-easing库来创建贝塞尔缓动函数。CSS3动画原生支持bezier-easing所以如果使用HTML/CSS方式绘制元素我们可以尽量使用CSS3动画。
## 小试牛刀
最后我希望你能利用我们今天学到的时序动画来实现一个简单的动画效果。就是我们假设有一个半径为10px的弹性小球我们让它以自由落体的方式下落200px高度。在这个过程中小球每次落地后弹起的高度会是之前的一半然后它会不断重复自由下落的过程直到静止在地面上。
你能试着用标准动画模型封装好的Animator模块来实现这个效果吗Animator模块的代码你可以在Github仓库中找到也可以直接按照我们前面讲解内容自己实现一下。
## 源码
本节课的完整示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/animate)
## 推荐阅读
[1] [牛顿迭代法](https://zh.wikipedia.org/wiki/%E7%89%9B%E9%A1%BF%E6%B3%95)<br>
[2] [Bezier-easing](https://github.com/gre/bezier-easing)<br>
[3] [Easing.net](https://easings.net/)

View File

@@ -0,0 +1,560 @@
<audio id="audio" title="19 | 如何用着色器实现像素动画?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/4b/d09a91a42d2be033bed397fe96d0934b.mp3"></audio>
你好,我是月影。
上节课我们以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 &lt; 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;
}
```
<img src="https://static001.geekbang.org/resource/image/e5/8a/e5cfe9afc454013c3913bfbb03b9548a.gif" alt="" title="会飞的小鸟">
利用片元着色器实现固定帧动画的关键部分是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(() =&gt; {
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);
});
```
<img src="https://static001.geekbang.org/resource/image/2d/cb/2dbbyy2470cb07ed2e67e4d43aee21cb.gif" alt="" title="逆时针旋转的红色正方形">
当然我们也可以使用上一节课得到的标准动画模型来实现。具体来说就是定义一个新的Animator对象然后在Animator对象的方法中更新rotation属性。使用标准模型能更加精确地控制图形的旋转效果代码如下
```
const animator = new Animator({duration: 2000, iterations: Infinity});
animator.animate(renderer, ({target, timing}) =&gt; {
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;
}
```
<img src="https://static001.geekbang.org/resource/image/81/34/8176f915e61f55ae0c54da3283b23134.gif" alt="" title="顺时针旋转的红色正方形">
你发现了吗顶点着色器和片元着色器实现的旋转动画方向正好相反。为什么会出现这样的情况呢因为在顶点着色器中我们直接改变了顶点坐标所以这样实现的旋转动画和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);
}
```
<img src="https://static001.geekbang.org/resource/image/9b/8c/9b7fe730d2f82df5f05b015eb08aab8c.gif" alt="" title="大量重复的旋转正方形">
## 如何在着色器中实现缓动函数与非线性插值
在前面的例子中我们使用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}) =&gt; {
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();
```
<img src="https://static001.geekbang.org/resource/image/be/cf/beeb13aa9565a4b066883d8a08632acf.gif" alt="" title="水平向右匀速运动的红色正方形">
此外我们还可以通过缓动函数来实现非匀速运动。而且我们既可以将缓动函数用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();
```
<img src="https://static001.geekbang.org/resource/image/28/eb/28934b0fd2dayy775f20b48cb18d1ceb.jpg" alt="" title="从左往右,由红色线性过渡到绿色">
通过执行上面的代码我们可以得到一个长方形它的颜色会从左到右由红色线性地过渡到绿色。如果想要实现非线性的颜色过渡我们就不能采用这种方式了我们可以采用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函数来实现如下的插值效果
<img src="https://static001.geekbang.org/resource/image/87/38/87519e16885ca453475173318d459d38.jpg" alt="">
另外我们还可以像利用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 &lt; 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;
}
```
使用贝塞尔曲线缓动函数,我们能够实现更加丰富多彩的插值效果。
<img src="https://static001.geekbang.org/resource/image/24/42/24453cd8f4382c7411da6409aa8cd942.jpg" alt="" title="贝塞尔曲线插值色带">
## 如何在片元着色器中实现随机粒子动画
我们知道,使用片元着色器还可以实现非常复杂的图形动画,包括粒子动画、网格动画以及网格噪声动画等等。网格动画和网格噪声我们前面都详细讲过,这里我们就重点来说说怎么实现粒子动画效果。
```
#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变量来实现动画。
这样一来,我们就能绘制出数量非常多的点,并且让它们沿着随机轨迹运动。最终的视觉效果如下:
<img src="https://static001.geekbang.org/resource/image/02/a4/0282f5561b0c323f2d81dde6f4f3aaa4.gif" alt="" title="粒子动画效果">
像这样流畅的动画效果因为实现的过程中会涉及非常多点的运算如果不用shader我们几乎是无法完成的。
## 要点总结
这节课我们学习了用WebGL实现动画的方法。
如果是实现固定帧动画在WebGL中我们可以把准备好的图片作为纹理然后动态修改纹理坐标。
如果是实现非固定帧动画我们可以通过uniform将变化的属性作为参数传给着色器处理。上节课的标准动画模型在WebGL中依然可以使用我们可以利用它计算出属性再传入着色器执行渲染。
实际上今天讲的方法与用HTML/CSS、SVG、Canvas2D实现动画的基本原理是一样的。只不过WebGL中的很多计算是需要用JavaScript和GLSL也就是Shader来配合进行的。
这节课的实战例子比较多,我建议你好好研究一下。毕竟,使用片元着色器实现动画效果的思路,我们还会在后续课程中经常用到。
## 小试牛刀
<li>
今天我们在Shader中通过矩阵运算实现了图形的旋转和平移你能用学到的知识完善矩阵运算来实现缩放、旋转、平移和扭曲变换以及它们的组合效果吗
</li>
<li>
结合今天的内容,你可以试着实现一个粒子效果:让一张图片从中心爆炸开来,炸成碎片并最终消失。
</li>
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
本节课完整示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/animate_webgl)

View File

@@ -0,0 +1,534 @@
<audio id="audio" title="20 | 如何用WebGL绘制3D物体" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/63/6e2ea014a03e3ab680e227176be88563.mp3"></audio>
你好我是月影。这一节课开始我们学习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个顶点。
<img src="https://static001.geekbang.org/resource/image/47/4e/47cc2a856e7b2f675467f7484373e74e.jpeg" alt="" title="立方体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) =&gt; {
positions.push(vertices[i]);
color.push(colors[colorIdx % colorLen]);
});
cells.push(
[0, 1, 2].map(i =&gt; i + cellsIdx),
[0, 2, 3].map(i =&gt; 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();
```
<img src="https://static001.geekbang.org/resource/image/39/4d/39bc7f588350b3c990c4cd0e2b616e4d.jpeg" alt="" title="立方体的正视图,在画布上只呈现了一个红色正方形,因为其他面被遮挡住了">
## 投影矩阵变换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);
}
```
<img src="https://static001.geekbang.org/resource/image/19/40/19ee7c8dbddee10663e83b00eb740040.jpeg" alt="">
投影矩阵不仅可以用来改变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();
```
<img src="https://static001.geekbang.org/resource/image/ff/69/ff4dd2c260eb6aba433b4yye4cef7569.gif" alt="">
到这里,我们就完成了一个旋转的立方体。
## 如何用WebGL绘制圆柱体
立方体还是比较简单的几何体,那类似的,我们还可以构建顶点和三角形,来绘制更加复杂的图形,比如圆柱体、球体等等。这里,我再用绘制圆柱体来举个例子。
我们知道圆柱体的两个底面都是圆,我们可以用割圆的方式对圆进行简单的三角剖分,然后把圆柱的侧面用上下两个圆上的顶点进行三角剖分。
<img src="https://static001.geekbang.org/resource/image/30/01/30a5c31ea777ef8b5f9586d5bb6ed401.jpg" alt="">
具体的算法如下:
```
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 &lt;= 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]) =&gt; [x, y, -h]));
for(let i = 1; i &lt; 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]) =&gt; [x, y, h]));
for(let i = 1; i &lt; 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(() =&gt; colorCap));
// 侧面
offset = positions.length;
for(let i = 1; i &lt; cap.length; i++) {
const a = [...cap[i], h];
const b = [...cap[i], -h];
const nextIdx = i &lt; 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效果如下图所示
<img src="https://static001.geekbang.org/resource/image/b1/06/b14b73c131c9bf29887bcbbf8df4d506.gif" alt="">
所以我们看到用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和bn是它们的法向量那我们可以得到公式n = a X b。
<img src="https://static001.geekbang.org/resource/image/e5/85/e5bddfd9b16c6f325c0c2e95e428a285.jpeg" alt="">
根据这个公式,我们可以通过以下方法求出侧面的法向量:
```
const tmp1 = [];
const tmp2 = [];
// 侧面
offset = positions.length;
for(let i = 1; i &lt; cap.length; i++) {
const a = [...cap[i], h];
const b = [...cap[i], -h];
const nextIdx = i &lt; 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)坐标处的点光源与几何体法线的夹角余弦。那根据物体漫反射模型,光照强度等于光线与法向量夹角的余弦。
<img src="https://static001.geekbang.org/resource/image/f6/7b/f620694bfbf78143dd99965cc111b97b.jpeg" alt="">
因此,我们求出这个余弦值,就能在片元着色器叠加光照了。操作代码和实现效果如下:
```
#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;
}
```
<img src="https://static001.geekbang.org/resource/image/e8/03/e87fe067e3fc7461d4fd489e12461003.gif" alt="" title="用法向量计算出的光照效果">
## 要点总结
今天我们以绘制立方体和圆柱体为例讲了用WebGL绘制三维几何体的基本原理。3D绘图在原理上和2D绘图几乎是完全一样的就是构建顶点数据然后将数据送入缓冲区执行绘制。只是2D绘图用二维顶点数据而3D绘图用三维定点数据。
另外3D绘图时我们除了构造顶点数据之外还可以构造其他的数据比较有用的是法向量。法向量是垂直于物体表面三角网格的向量使用它可以来计算光照。在片元着色器中我们拿到的是经过模型矩阵变换后的顶点使用法向量我们还需要用一个法向量矩阵对它进行变换。法向量矩阵是模型矩阵的逆转置矩阵它是一个3X3的矩阵将法向量经过法向量矩阵变换后我们就可以和片元着色器中的顶点进行运算了。
## 小试牛刀
1. 在今天的课程中我们绘制出了正立方体。那你能修改例子中的cube函数构造出非正立方体吗新的cube函数签名如下
```
function cube(width = 1.0, height = 1.0, depth = 1.0, colors = [[1, 0, 0, 1]])
```
1. 你能用我们今天讲的方法绘制出一个正四面体,并给不同的面设置不同的颜色,然后在正四面体上实现点光源光照效果吗?
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
课程中完整示例代码见 [GitHub仓库](https://github.com/akira-cn/graphics/tree/master/3d-basic)

View File

@@ -0,0 +1,362 @@
<audio id="audio" title="21 | 如何添加相机,用透视原理对物体进行投影?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/cf/1c84yy16673fc5508a57cee04709c9cf.mp3"></audio>
你好,我是月影。
上节课我们在绘制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)拍摄图像的最终效果就如下所示。
<img src="https://static001.geekbang.org/resource/image/0c/3d/0cb89b225568d718d3b0e29ec2107a3d.jpeg" alt="">
## 剪裁空间和投影对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);
...
}
```
<img src="https://static001.geekbang.org/resource/image/0c/69/0ce1b0da1ddaf4c993f928a6fc16a869.jpeg" alt="" title="给x、y增加0.5平移后的效果">
对于只有x、y的二维坐标系来说这一点很好理解。但是对于三维坐标系来说不仅x、y轴会被剪裁z轴同样也会被剪裁。我们还是直接修改代码给z轴增加0.5的平移。你会看到,最终绘制出来的图形非常奇怪。
<img src="https://static001.geekbang.org/resource/image/a0/8c/a0808ebaf37784b633271e1b41047e8c.jpeg" alt="" title="给z轴增加0.5平移后的效果">
会显示这么奇怪的结果就是因为z轴超过范围的部分也被剪裁掉了导致投影出现了问题。
既然是投影出现了问题我们先回想一下我们都对z轴做过哪些投影操作。在绘制圆柱体的时候我们只是用投影矩阵非常简单地反转了一下z轴除此之外没做过其他任何操作了。所以为了让图形在剪裁空间中正确显示我们不能只反转z轴还需要将图像从三维空间中**投影**到剪裁坐标内。那么问题来了,图像是怎么被投影到剪裁坐标内的呢?
一般来说,投影有两种方式,分别是**正投影**与**透视投影**。你可以结合我给出的示意图,来理解它们各自的特点。
**首先是正投影**,它又叫做平行投影。正投影是将物体投影到一个长方体的空间(又称为视景体),并且无论相机与物体距离多远,投影的大小都不变。
[<img src="https://static001.geekbang.org/resource/image/d6/b1/d69c5a24cf92bea9ebf24f6222a225b1.jpeg" alt="" title="正投影示意图"> ](https://glumes.com/post/opengl/opengl-tutorial-projection-matrix/)
而**透视投影**则更接近我们的视觉感知。它投影的规律是,离相机近的物体大,离相机远的物体小。与正投影不同,正投影的视景体是一个长方体,而透视投影的视景体是一个棱台。
[<img src="https://static001.geekbang.org/resource/image/fd/43/fd76c623daba4a80f6c557e03a82bb43.jpeg" alt="" title="透视投影示意图">](https://glumes.com/post/opengl/opengl-tutorial-projection-matrix/)
知道了不同投影方式的特点我们就可以根据投影方式和给定的参数来计算投影矩阵了。因为数学推导过程比较复杂我在这里就不详细推导了直接给出对应的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]); // 设置相机位置
```
<img src="https://static001.geekbang.org/resource/image/05/yb/05fdd4fe5e56eaf27e1f1a00825e8yyb.jpeg" alt="">
接下来,我们再试一下对圆柱体进行透视投影。在进行透视投影的时候,我们将相机的位置放在(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]); // 设置相机位置
```
<img src="https://static001.geekbang.org/resource/image/cb/38/cb20b6589554e7bae62567d68bff7938.jpeg" alt="">
我们发现在透视投影下距离观察者相机近的部分大距离它远的部分小。这更符合真实世界中我们看到的效果所以一般来说在绘制3D图形时我们更偏向使用透视投影。
## 3D绘图标准模型
实际上通过上节课和刚才的内容我们已经能总结出3D绘制几何体的基本数学模型也就是3D绘图的**标准模型**。这个标准模型一共有四个矩阵,它们分别是:**投影矩阵、视图矩阵ViewMatrix、模型矩阵ModelMatrix、法向量矩阵NormalMatrix**。
其中,前三个矩阵用来计算最终显示的几何体的顶点位置,第四个矩阵用来实现光照等效果。比较成熟的图形库,如[ThreeJS](https://threejs.org/)、[BabylonJS](https://www.babylonjs.com/)基本上都是采用这个标准模型来进行3D绘图的。所以理解这个模型也有助于增强我们对图形库的认识帮助我们更好地去使用这些流行的图形库。
在前面的课程中因为WebGL原生的API在使用上比较复杂所以我们使用了简易的gl-renderer库来简化2D绘图过程。而3D绘图是一个比2D绘图更加复杂的过程即使是gl-renderer库也有点力不从心我们需要更加强大的绘图库来简化我们的绘制以便于我们能够把精力专注于理解图形学本身的核心内容。
当然使用ThreeJS或BabeylonJS都是不错的选择。但是在这节课中我会使用一个更加轻量级的图形库叫做[OGL](https://github.com/oframe/ogl)。它拥有我们可视化绘图需要的所有基本功能而且相比于ThreeJS等流行图形库它的AP相对更底层、更简单一些。因此不会有太多高级的特性对我们的学习造成干扰。
接下来,我就用这个库来绘制一些简单的圆柱体、立方体等等,让你对这个库的使用有一个全面的了解。
## 如何使用OGL绘制基本的几何体
OGL库使用的也是我们刚才说的标准模型因此使用它所以绘制几何体非常简单分成以下7个步骤如下图所示。
<img src="https://static001.geekbang.org/resource/image/5b/4c/5b2b4622f1bea87199788d20a2629b4c.jpg" alt="">
接下来,我们详细来看看每一步的操作。
首先是创建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});
}
```
<img src="https://static001.geekbang.org/resource/image/b8/41/b8eb9696d1201035d962bf39f6105141.gif" alt="">
## 要点总结
在这一节课我们在三维空间里引入了相机和视图矩阵的概念相机分为透视相机和正交相机它们有不同的投影方式并且设置它们还可以改变剪裁空间。视图矩阵和前一节课介绍的投影矩阵、模型矩阵、法向量矩阵一起构成了3D绘图标准模型这是一般的图形库遵循的标准绘图方式。
为了巩固学习到的知识我们使用OGL库来尝试绘制不同的3D几何体我们依次用OGL绘制了球体、立方体、圆柱体和环面。OGL绘制图形的基本步骤可以总结为7步如下图
<img src="https://static001.geekbang.org/resource/image/5b/4c/5b2b4622f1bea87199788d20a2629b4c.jpg" alt="">
## 小试牛刀
1. 在上面的例子里使用OGL绘制的球体看起来不是很圆你可以研究一下[OGL的代码](https://github.com/oframe/ogl),修改一下创建球体的参数,让它看起来更圆。
1. 你能试着修改一下片元着色器让上面绘制的4个几何体呈现不同的颜色吗将它们分别改成红色、黄色、蓝色和绿色。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
课程中完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/3d-camera)
## 推荐阅读
[OGL](https://github.com/oframe/ogl)

View File

@@ -0,0 +1,308 @@
<audio id="audio" title="22 | 如何用仿射变换来移动和旋转3D物体" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/73/a7/73d1bbb48920fb81221f3d4512def1a7.mp3"></audio>
你好,我是月影。
在前面的课程里,我们学习过使用仿射变换来移动和旋转二维图形。那在三维世界中,想要移动和旋转物体,我们也需要使用仿射变换。
但是,仿射变换该怎么从二维扩展到三维几何空间呢?今天,我们就来看一下三维仿射变换的基本方法,以及怎么对它进行优化。
三维仿射变换和二维仿射变换类似,也包括平移、旋转与缩放等等,而且具体的变换公式也相似。
比如,对于平移变换来说,如果向量$P(x_{0},y_{0},z_{0})$沿着向量 $Q(x_{1},y_{1},z_{1})$平移,我们只需要让$P$加上$Q$,就能得到变换后的坐标。
$$<br>
\left\{\begin{array}{l}<br>
x=x_{0}+x_{1} \\\<br>
y=y_{0}+y_{1} \\\<br>
z=z_{0}+z_{1}<br>
\end{array}\right.<br>
$$
再比如,对于缩放变换来说,我们直接让三维向量乘上标量,就相当于乘上要缩放的倍数就可以了。最后我们得到的三维缩放变换矩阵如下:
$$<br>
M=\left[\begin{array}{ccc}<br>
s_{x} &amp; 0 &amp; 0 \\\<br>
0 &amp; s_{y} &amp; 0 \\\<br>
0 &amp; 0 &amp; s_{z}<br>
\end{array}\right]<br>
$$
而且,我们也可以使用齐次矩阵来表示三维仿射变换,通过引入一个新的维度,就可以把仿射变换转换为齐次矩阵的线性变换了。
$$<br>
M=\left[\begin{array}{ccc}<br>
M &amp; 0 \\\<br>
0 &amp; 1<br>
\end{array}\right]<br>
$$
这个齐次矩阵是一个4X4的矩阵其实它就是我们在[第20节课](https://time.geekbang.org/column/article/269494)提到的模型矩阵ModelMatrix
总之,对于三维的仿射变换来说,平移和缩放都只是增加一个$z$分量,这和二维放射变换没有什么不同。但对于物体的旋转变换,三维就要比二维稍微复杂一些了。因为二维旋转只有一个参考轴,就是$z$轴,所以二维图形旋转都是围绕着$z$轴的。但是,三维物体的旋转却可以围绕$x、y、z$,这三个轴其中任意一个轴来旋转。
因此,这节课,我们就把重点放在处理三维物体的旋转变换上。
## 使用欧拉角来旋转几何体
我们先来看一下三维物体的旋转变换矩阵:
$$绕y轴旋转R_{y}=\left[\begin{array}{ccc}\cos \alpha &amp; 0 &amp; \sin \alpha \\\ 0 &amp; 1 &amp; 0 \\\ -\sin \alpha &amp; 0 &amp; \cos \alpha\end{array}\right]$$
$$绕x轴旋转R_{x}=\left[\begin{array}{ccc}1 &amp; 0 &amp; 0 \\\ 0 &amp; \cos \beta &amp; -\sin \beta \\\ 0 &amp; \sin \beta &amp; \cos \beta\end{array}\right]$$
$$绕z轴旋转R_{z}=\left[\begin{array}{ccc}\cos \gamma &amp; -\sin \gamma &amp; 0 \\\ \sin \gamma &amp; \cos \gamma &amp; 0 \\\ 0 &amp; 0 &amp; 1\end{array}\right]$$
你会看到,我们使用了三个旋转矩阵$Ry、Rx、Rz$来描述三维的旋转变换。这三个旋转矩阵分别表示几何体绕$y$轴、$x$轴、$z$轴转过$α、β、γ$角。而这三个角,就叫做**欧拉角。**
### 什么是欧拉角?
那什么是欧拉角呢?欧拉角是描述三维物体在空间中取向的标准数学模型,也是航空航天普遍采用的标准。对于在三维空间里的一个[参考系](https://baike.baidu.com/item/%E5%8F%82%E7%85%A7%E7%B3%BB/1531482?fromtitle=%E5%8F%82%E8%80%83%E7%B3%BB&amp;fromid=823115&amp;fr=aladdin),任何坐标系的取向,都可以用三个欧拉角来表示。
举个例子,下图中这个飞机的飞行姿态,可以由绕$x$轴的旋转角度(翻滚机身)、绕$y$轴的旋转角度(俯仰),以及绕$z$轴的旋转角度(偏航)来表示。
<img src="https://static001.geekbang.org/resource/image/0e/7d/0e9540f7a6da478eb1a83d38e9d3d17d.jpeg" alt="">
也就是说,这个飞机的姿态可以由这三个欧拉角来确定。具体的表示公式就是$Rx、Ry、Rz$,这三个旋转矩阵相乘。
$$M=R_{y} \times R_{x} \times R_{z}$$
这里,我们是按照$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$;另一种叫做**TaitBryan angles**,也包含六种顺规,分别是$x-y-z、y-z-x、z-x-y、x-z-y、z-y-x、 y-x-z$。
<img src="https://static001.geekbang.org/resource/image/83/14/8328b6492bf69d900760fb8e9bfbe814.jpeg" alt="">
显然,我们采用的 $y-x-z$ 顺规,属于**TaitBryan angles。**
不同的欧拉角顺规虽然表示方法不同,但它们本质上还是欧拉角,都可以表示三维几何空间中的任意取向。所以,我们在绘制三维图形的时候,使用任何一种表示法都可以。今天,我就以$y-x-z$顺规为例来接着讲。
采用$y-x-z$顺规的欧拉角之后,我们能得到如下的旋转矩阵结果:
<img src="https://static001.geekbang.org/resource/image/fa/20/fa3c632502410545bf68672de376ee20.jpeg" alt="">
### 如何使用欧拉角来旋转几何体?
接下来,我们通过一个例子来实际体会,使用欧拉角旋转几何体的具体过程。
这里我们还是用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是一份几何模型文件内容类似于下面这样
```
{
&quot;position&quot;: [0.752, 1.061, 0.0, 0.767...],
&quot;normal&quot;: [0.975, 0.224, 0.0, 0.975...],
&quot;uv&quot;: [0.745, 0.782, 0.705, 0.769...]
}
```
其中position、normal、uv是顶点数据我们比较熟悉分别是顶点坐标、法向量和纹理坐标。这样的数据一般是由设计工具直接生成的不需要我们来计算。
接下来我们加载飞机的纹理图片同样要先封装一个加载图片纹理的函数。在函数里我们用img元素加载图片然后将图片赋给对应的纹理对象。函数代码如下
```
function loadTexture(src) {
const texture = new Texture(gl);
return new Promise((resolve) =&gt; {
const img = new Image();
img.onload = () =&gt; {
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});
```
最终,我们就能得到可以随意调整欧拉角的飞机模型了,效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/4b/c7/4b803c9b9b4114faeaaf68f177d952c7.gif" alt="">
## 如何理解万向节锁?
使用欧拉角来操作几何体的方向,虽然很简单,但是有一个小缺陷,这个缺陷叫做万向节锁(Gimbal Lock)。那万向节锁是什么呢,我们通过上面的例子来解释。
你会发现当我们分别改变飞机的alpha、beta、theta值时飞机会做出对应的姿态调整包括偏航改变alpha、翻滚改变beta和俯仰改变theta
但是如果我们将beta固定在正负90度改变alpha和beta我们会发现一个奇特的现象
<img src="https://static001.geekbang.org/resource/image/f6/06/f60deb3ec9096dab9bef78438c660e06.gif" alt="">
如上图所示我们将beta设为90度不管改变alpha还是改变theta飞机都绕着$y$轴旋转,始终处于一个平面上。也就是说,本来飞机姿态有$x、y、z$三个自由度,现在$y$轴被固定了,只剩下两个自由度了,这就是万向节锁。
万向节锁,并不是真的“锁”住。而是在特定的欧拉角情况下,姿态调整的自由度丢失了。而且,只要是欧拉角,不管我们使用哪一种顺规,万向节锁都会存在。这该怎么解决呢?
要避免万向节锁的产生,我们只能使用其他的数学模型,来代替欧拉角描述几何体的旋转。其中一个比较好的模型是**四元数**Quaternion
## 使用四元数来旋转几何体
四元数是一种高阶复数,一个四元数可以表示为:$q = w + xi + yj + zk$。其中,$i、j、k$是三个虚数单位,$w$是标量,它们满足$i^{2} = j^{2} = k^{2} = ijk = -1$。如果我们把 $xi + yj + zk$ 看成是一个向量,那么四元数$q$又可以表示为 $q=(v, w)$,其中$v$是一个三维向量。
我们可以用单位四元数来描述3D旋转。所谓单位四元数就是其中的参数满足 $x^{2} + y^{2} + z^{2} + w^{2}= 1$。单位四元数对应的旋转矩阵如下:
$$<br>
R(q)=\left[\begin{array}{ccc}<br>
1-2 y^{2}-2 z^{2} &amp; 2 x y-2 z w &amp; 2 x z+2 y w \\\<br>
2 x y+2 z w &amp; 1-2 x^{2}-2 z^{2} &amp; 2 y z-2 x w \\\<br>
2 x z-2 y w &amp; 2 y z+2 x w &amp; 1-2 x^{2}-2 y^{2}<br>
\end{array}\right]<br>
$$
这个旋转矩阵的[数学推导过程](https://krasjet.github.io/quaternion/quaternion.pdf)比较复杂,我们只要记住这个公式就行了。
与欧拉角相比,四元数没有万向节死锁的问题。而且与旋转矩阵相比,四元数只需要四个分量就可以定义,模型上更加简洁。但是,四元数相对来说没有旋转矩阵和欧拉角那么直观。
### 四元数与轴角
四元数有一个常见的用途是用来处理**轴角**。所谓轴角,就是在三维空间中,给定一个由单位向量表示的轴,以及一个旋转角度$$,以此来表示几何体绕该轴旋转$$角。
[<img src="https://static001.geekbang.org/resource/image/29/85/29861c395af520f3906c4b7fe20db385.jpeg" alt="" title="轴角">](https://zinghd.gitee.io/Att-err3/)
绕单位向量$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);
```
那么,随着我们修改轴或者修改旋转角,物体就会绕着轴旋转。效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/5f/7b/5fa4f4629db4aa3e0ec974c974a9637b.gif" alt="">
这样我们就实现了用四元数让飞机沿着某个轴旋转的效果了。这其中最重要的一步是要你理解怎么根据旋转轴和轴角来计算对应的四元数也就是updateQuaternion函数里面做的事情。然后我们将这个更新后的四元数赋给飞机的mesh对象就可以更新飞机的位置实现飞机绕轴的旋转。我只在课程中给出了关键部分的代码你可以去GitHub仓库里找到对应例子的完整代码。
## 要点总结
今天我们学习了使用三维仿射变换来移动和旋转3D物体。三维仿射变换在平移和缩放变换上的绘制方法与二维仿射变换类似只不过增加了一个z维度。但是对于旋转变换三维放射变换就要复杂一些了因为3D物体可以绕$x、y、z$轴中任意一个方向旋转。
那想要旋转三维几何体,我们可以使用欧拉角。欧拉角实际上就等于,绕$x、y、z$三个轴方向的旋转矩阵相乘,相乘的顺序就是欧拉角的顺规。
虽然顺规有很多种,但是选择不同的顺规,只是表达方式不一样,最终结果是等价的,都是欧拉角。那在这节课中,我们采用$y-x-z$顺规它也是OGL库默认采用的。
但是欧拉角有一个万向节锁的问题,就是当$β$角旋转到正负90度的时候我们无论怎么改变$α、γ$角,都只能让物体在一个水平面上运动。而且,只要我们使用欧拉角,就无法避免万向节锁的出现。
为了避免万向节锁,我们可以用四元数来旋转几何体。除此之外,四元数还有一个作用是可以用来构造轴角,让物体沿着某个具体的轴旋转。你可以回想一下我们刚刚实现的绕轴飞行的飞机。
## 小试牛刀
你可以试着利用放射变换来实现一个旋转的3D陀螺效果。陀螺的形状可以用一个简单的圆锥体来表示。旋转的过程中你可以让陀螺绕自身的中间轴旋转也可以让它绕着三维空间某个固定的轴旋转。快来动手试一试吧。效果如下
<img src="https://static001.geekbang.org/resource/image/00/37/00bcb30bc90yy33a18640fdcf68d4a37.gif" alt="">
除了旋转的飞机和旋转的陀螺,你还能实现哪些旋转的物体呢?不如也把这篇文章分享给你的朋友们,一起来实现一下吧!
## 源码
课程中详细示例代码[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/3d-model)
## 推荐阅读
[1] [进一步理解欧拉角](https://en.wikipedia.org/wiki/Euler_angles)<br>
[2] [欧拉角的不同表示方法参考文档](https://en.wikipedia.org/wiki/Euler_angles)<br>
[3] [四元数与三维旋转](https://krasjet.github.io/quaternion/quaternion.pdf)<br>
[4] [三维旋转:欧拉角、四元数、旋转矩阵、轴角之间的转换](https://zhuanlan.zhihu.com/p/45404840)

View File

@@ -0,0 +1,377 @@
<audio id="audio" title="23 | 如何模拟光照让3D场景更逼真" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/d2/a1207f0e98686678e401dd18122d2fd2.mp3"></audio>
你好,我是月影。
在我们生活的真实物理世界中充满了各种类型的光。在这些光的照射下我们看到的每个物体都会呈现不同的色彩。所以要想让我们渲染出的3D物体看起来更自然、逼真很重要的一点就是模拟各种光照的效果。那今天我们就一起来学习一下怎么模拟光照效果。
物体的光照效果是由**光源、介质(物体的材质)和反射类型**决定的,而反射类型又由**物体的材质特点**决定。在3D光照模型中根据不同的光源特点我们可以将光源分为4种不同的类型分别是环境光Ambient Light、平行光Directional Light、点光源Positional Light和聚光灯Spot Light。而物体的反射类型则分为漫反射和镜面反射两种。
当然了,实际自然界中的光照效果,肯定比我说的要复杂得多。但现阶段,我们弄明白这三个决定因素,就能模拟出非常真实的光照效果了。
## 如何给物体增加环境光效果?
我们先来说说怎么给物体增加环境光效果。
那什么是环境光呢?环境光就是指物体所在的三维空间中天然的光,它充满整个空间,在每一处的光照强度都一样。环境光没有方向,所以,物体表面反射环境光的效果,只和环境光本身以及材质的反射率有关。
物体在环境光中呈现的颜色,我们可以利用下面的公式来求。其中,环境光的颜色为$L$,材质对光的反射率为$R$。
$$<br>
C=L R=\left[\begin{array}{c}<br>
L_{r} \times R_{r} \\\<br>
L_{g} \times R_{g} \\\<br>
L_{b} \times R_{b}<br>
\end{array}\right]<br>
$$
接着,我们创建一个片元着色器,代码如下:
```
precision highp float;
uniform vec3 ambientLight;
uniform vec3 materialReflection;
void main() {
gl_FragColor.rgb = ambientLight * materialReflection;
gl_FragColor.a = 1.0;
}
```
我们用这个着色器创建[WebGL着色器程序](https://github.com/akira-cn/graphics/blob/master/lights/ambient-light.html)传入环境光ambientLight和材质反射率materialReflection就可以渲染出各种颜色的几何体了。
<img src="https://static001.geekbang.org/resource/image/05/32/05e0d0ba479de13e7a7df251b27f3332.gif" alt="">
那你可能有疑问了,通过这样渲染出来的几何体颜色,与我们之前通过设置颜色属性得到的颜色有什么区别呢?
在前面的课程里,我们绘制的几何体只有颜色属性,但是在光照模型里,我们把颜色变为了环境光和反射率两个属性。这样的模型更加接近于真实世界,也让物体的颜色有了更灵活的控制手段。比如,我们修改环境光,就可以改变整个画布上所有受光照模型影响的几何体的颜色,而如果只是像之前那样给物体分别设置颜色,我们就只能一一修改这些物体各自的颜色了。
最后,我希望你能记住环境光的两个特点。
首先因为它在空间中均匀分布所以在任何位置上环境光的颜色都相同。其次它与物体的材质有关。如果物体的RGB通道反射率不同的话那么它在相同的环境光下就会呈现出不同的颜色。因此如果环境光是白光#FFF),那么物体呈现的颜色就是材质反射率表现出的颜色,也就是物体的固有颜色。
## 如何给物体增加平行光效果?
除了环境光以外,平行光也很常见。与环境光不同,平行光是朝着某个方向照射的光,它能够照亮几何体的一部分表面。
[<img src="https://static001.geekbang.org/resource/image/0b/ec/0bb290cca1f160666d0ee2fa2a1edeec.jpeg" alt="" title="平行光示意图">](https://blog.csdn.net/lufy_Legend/article/details/38908403)
而且,平行光除了颜色这个属性之外,还有方向,它属于有向光。有向光在与物体发生作用的时候,根据物体的材质特性,会产生两种反射,一种叫做**漫反射**Diffuse reflection另一种叫做**镜面反射**Specular reflection而一个物体最终的光照效果是漫反射、镜面反射以及我们前面说的环境光叠加在一起的效果。因为内容比较多所以这节课我们先来讨论光源的漫反射效果。下节课我们再继续讨论光源的镜面反射以及多个光源混合的反射效果。如下图所示
[<img src="https://static001.geekbang.org/resource/image/7c/fc/7c0d6511824f6c5c1b2e8d5ebc0d0cfc.jpeg" alt="" title="多种反射叠加的效果示意图">]((https://en.wikipedia.org/wiki/Phong_reflection_model))
有向光的漫反射在各个方向上的反射光均匀分布,反射强度与光的射入方向与法线的夹角的余弦成正比。
[<img src="https://static001.geekbang.org/resource/image/30/07/30955b0fc8785b8157801e3d37155107.jpeg" alt="">](https://www.photokonnexion.com/definition-diffuse-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,
},
});
...
```
最终显示的效果如下图所示,当旋转相机位置的时候,我们看到物体因为光照,不同方向表面的明暗度不一样。
<img src="https://static001.geekbang.org/resource/image/61/e7/61d3462844c3f3cf6e1978695b9850e7.gif" alt="">
## 如何添加点光源?
除了平行光之外,点光源和聚光灯也都是有向光。
点光源顾名思义,就是指空间中某一点发出的光,与方向光不同的是,点光源不仅有方向属性,还有位置属性。因此计算点光源的光照,我们要先根据光源位置和物体表面相对位置来确定方向,然后再和平行光一样,计算光的方向和物体表面法向的夹角。计算过程要比平行光稍微复杂一些。
[<img src="https://static001.geekbang.org/resource/image/f6/24/f61c6d3fbfd1e9636ffd6604ed2e1a24.jpg" alt="" title="点光源与平行光">](https://subscription.packtpub.com/book/web_development/9781788629690/6/ch06lvl1sec86/time-for-action-directional-point-lights)
对于平行光来说,只要法向量相同,方向就相同,所以我们可以直接在顶点着色器中计算方向。但点光源因为其方向与物体表面的相对位置有关,所以我们不能在顶点着色器中计算,需要在片元着色器中计算。
因此,计算点光源光照效果的第一步,就是要在顶点着色器中,将物体变换后的坐标传给片元着色器,代码如下:
```
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)位置,颜色为白光,得到的效果如下图所示。
<img src="https://static001.geekbang.org/resource/image/69/3b/6901e4792cf5e0dc8572e78f2e1a6c3b.gif" alt="">
### 点光源的衰减
但是,前面的计算过程都是理想状态下的。而真实世界中,点光源的光照强度会随着空间的距离增加而衰减。所以,为了实现更逼真的效果,我们必须要把光线衰减程度也考虑进去。光线的衰减程度,我们一般用衰减系数表示。衰减系数等于一个常量$d_{0}$通常为1除以衰减函数$p$。
一般来说,衰减函数可以用一个二次多项式$P$来描述,它的计算公式为:
$$<br>
\left\{\begin{array}{l}<br>
P=A z^{2}+B z+C \\\<br>
d=\frac{d_{0}}{P}<br>
\end{array}\right.<br>
$$
其中$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),就能得到如下效果。把它和前一张图对比,你会发现,我们看到较远的几何体几乎没有光照了。这就是因为光线强度随着距离衰减了,也就更接近真实世界的效果。
<img src="https://static001.geekbang.org/resource/image/2e/cd/2efa6ee80cf2cefb41d4c17c8d455acd.gif" alt="">
## 如何给物体添加聚光灯效果?
最后,我们再来说说,怎么给物体添加聚光灯效果。
[<img src="https://static001.geekbang.org/resource/image/1b/58/1b24756ce9ff3252c2566yy312dd5c58.jpg" alt="" title="聚光灯示意图">](http://math.hws.edu/graphicsbook/c7/s2.html)
与点光源相比,聚光灯增加了方向以及角度范围,只有在这个范围内,光线才能照到。那该如何判断坐标是否在角度范围内呢?我们可以根据法向量与光线方向夹角的余弦值来判断坐标是否在夹角内,还记得我们在[第6节课](https://time.geekbang.org/column/article/256827)一开始就讨论的那道题目吗,这里就是具体应用。
所以,最终片元着色器中的代码如下:
```
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个参数来描述它们分别是
1. spotLightColor 聚光灯颜色
1. spotLightPosition 聚光灯位置
1. spotLightDecayFactor 聚光灯衰减系数
1. spotLightDirection 聚光灯方向
1. 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},
};
```
我们最终渲染出来的结果如下图:
<img src="https://static001.geekbang.org/resource/image/82/57/8249920558d7c7e444f0f5208f7aa757.gif" alt="">
## 要点总结
在这一节课我们主要讲了模拟真实世界中4种不同光源的方法这四种不同光源分别是**环境光、平行光、点光源和聚光灯**。
其中,环境光比较简单,它充满整个环境空间,在空间每一处的强度都相同。环境光作用于物体材质,根据材质对光的反射率,让材质呈现出不同的颜色。
另外三种光是有向光,它们作用于物体表面的效果,除了与物体材质的反射率有关,还和表面的朝向有关,所以我们需要计算光线方向和表面法向量的余弦值,用它来计算反射强度。
这三种光当中,平行光只有方向和颜色两个参数,点光源有位置、颜色和衰减系数三个参数,而聚光灯更加复杂,有位置、方向、角度范围、颜色和衰减系数五个参数。我们在着色器中根据这些参数进行计算,最终就能得到物体被光照后的漫反射结果。
## 小试牛刀
你会发现,这节课,我举的都是单一光源的例子,也就是空间中除了环境光以外,只有一个光源。但在真实的世界里,空间中肯定不止一种光源。你能试着修改例子中的代码,添加多个光源,让它们共同作用于物体吗?会实现什么样的效果呢?
欢迎在留言区分享你的答案和思考,也希望你能把这节课的内容转发出去,我们下节课再见!
## 源码
本课中完整的示例代码见 [GitHub仓库](https://github.com/akira-cn/graphics/tree/master/lights)

View File

@@ -0,0 +1,419 @@
<audio id="audio" title="24 | 如何模拟光照让3D场景更逼真" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/bd/b6a0fb32bbd5ee064647f90cc5c21ebd.mp3"></audio>
你好,我是月影。今天,我们接着来讲,怎么模拟光照。
上节课,我们讲了四种光照的漫反射模型。实际上,因为物体的表面材质不同,反射光不仅有漫反射,还有镜面反射。
<img src="https://static001.geekbang.org/resource/image/2a/d5/2ac147c6eb17d547a3ff355e58d65ed5.jpg" alt="" title="镜面反射与漫反射">
什么是镜面反射呢?如果若干平行光照射在表面光滑的物体上,反射出来的光依然平行,这种反射就是镜面反射。镜面反射的性质是,入射光与法线的夹角等于反射光与法线的夹角。
越光滑的材质,它的镜面反射效果也就越强。最直接的表现就是物体表面会有闪耀的光斑,也叫镜面高光。但并不是所有光都能产生镜面反射,我们上节课讲的四种光源中,环境光因为没有方向,所以不参与镜面反射。剩下的平行光、点光源、聚光灯这三种光源,都是能够产生镜面反射的有向光。
[<img src="https://static001.geekbang.org/resource/image/15/0f/15a2e5bcf5dc18b4e0e02efc9e79fc0f.jpeg" alt="" title="镜面高光">](https://commons.wikimedia.org)
那么今天我们就来讨论一下如何实现镜面反射然后将它和上节课的漫反射结合起来就可以实现标准的光照模型也就是Phong反射模型了从而能让我们实现的可视化场景更加接近于自然界的效果。
## 如何实现有向光的镜面反射?
首先,镜面反射需要同时考虑光的入射方向以及相机也就是观察者所在的方向。
[<img src="https://static001.geekbang.org/resource/image/f2/c9/f2f1bee42562acf44941aa2b077181c9.jpeg" alt="" title="观察者与光的入射方向">](https://blog.csdn.net/xyh930929/article/details/83418396)
接着我们再来说说怎么实现镜面反射效果一般来说需要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;
```
<img src="https://static001.geekbang.org/resource/image/36/09/36db1c0828a5a6e6aa1c4747431cee09.gif" alt="">
上面的代码是以点光源为例来实现的光斑其实只要是有向光都可以用同样的方法求出镜面反射只不过对应的入射光方向计算有所不同也就是着色器代码中的dir变量计算方式不一样。 你可以利用我上节课讲的内容,自己动手试试。
## 如何实现完整的Phong反射模型?
那在自然界中,除了环境光以外,其他每种光源在空间中都可以存在不止一个,而且因为几何体材质不同,物体表面也可能既出现漫反射,又出现镜面反射。
可能出现的情况这么多,分析和计算起来也会非常复杂。为了方便处理,我们可以把多种光源和不同材质结合起来,形成标准的反射模型,这一模型被称为[Phong反射模型](https://en.wikipedia.org/wiki/Phong_reflection_model)。
Phong反射模型的完整公式如下
$$<br>
I_{\mathrm{p}}=k_{\mathrm{a}} i_{\mathrm{a}}+\sum_{m \in \text { lights }}\left(k_{\mathrm{d}}\left(\hat{L}_{m} \cdot \hat{N}\right) i_{m, \mathrm{d}}+k_{\mathrm{s}}\left(\hat{R}_{m} \cdot \hat{V}\right)^{\alpha} i_{m, \mathrm{s}}\right)<br>
$$
公式里的$k_{\mathrm{a}}$、$k_{\mathrm{d}}$和$k_{\mathrm{s}}$分别对应环境反射系数、漫反射系数和镜面反射系数。$\hat{L}_{m}$是入射光,$N$是法向量,$\hat{R}_{m}$是反射光,$V$是视线向量。$i$是强度,漫反射和镜面反射的强度可考虑因为距离的衰减。$$是和物体材质有关的常量,决定了镜面高光的范围。
根据上面的公式我们把多个光照的计算结果相加就能得到光照下几何体的最终颜色了。不过这里的Phong反射模型实际上是真实物理世界光照的简化模型因为它只考虑光源的光作用于物体没有考虑各个物体之间的反射光。所以我们最终实现出的效果也只是自然界效果的一种近似不过这种近似也高度符合真实情况了。
在一般的图形库或者图形框架中会提供符合Phong反射模型的物体材质比如ThreeJS中就支持各种光源和反射材质。
下面我们来实现一下完整的Phong反射模型。它可以帮助你对这个模型有更深入的理解让你以后使用ThreeJS等其他图形库也能够更加得心应手。整个过程分为三步定义光源模型、定义几何体材质和实现着色器。
### 1. 定义光源模型
我们先来定义光源模型对象。环境光比较特殊我们将它单独抽象出来放在一个ambientLight的属性中而其他的光源一共有5个属性与材质无关我列了一张表放在了下面。
<img src="https://static001.geekbang.org/resource/image/88/d2/88ec1e9768fa4047964b19f8fc3d7fd2.jpg" alt="">
这样我们就可以定义一个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 &amp;&amp; !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) =&gt; {
lightData.directionalLightDirection.value.set(light.direction, idx * 3);
lightData.directionalLightColor.value.set(light.color, idx * 3);
});
[...this.pointLights].forEach((light, idx) =&gt; {
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) =&gt; {
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 &lt; MAX_LIGHT_COUNT; i++) {
vec3 dir = directionalLightDirection[i];
if(dir.x == 0.0 &amp;&amp; dir.y == 0.0 &amp;&amp; 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 &lt; MAX_LIGHT_COUNT; i++) {
vec3 decay = pointLightDecay[i];
if(decay.x == 0.0 &amp;&amp; decay.y == 0.0 &amp;&amp; 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 &lt; MAX_LIGHT_COUNT; i++) {
vec3 decay = spotLightDecay[i];
if(decay.x == 0.0 &amp;&amp; decay.y == 0.0 &amp;&amp; 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;
}
```
最终呈现的视觉效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/36/09/36db1c0828a5a6e6aa1c4747431cee09.gif" alt="">
你注意一下上图右侧的球体。因为我们一共设置了3个光源一个平行光、两个点光源它们都能够产生镜面反射。所以这些光源叠加在一起后这个球体就呈现出3个镜面高光。
## Phong反射模型的局限性
虽然phong反射模型已经比较接近于真实的物理模型不过它仍然是真实模型的一种近似。因为它没有考虑物体反射光对其他物体的影响也没有考虑物体对光线遮挡产生的阴影。
当然,我们可以完善这个模型。比如,将物体本身反射光(主要是镜面反射光)对其他物体的影响纳入到模型中。另外,我们也要考虑物体的阴影。当我们把这些因素更多地考虑进去的时候,我们的模型就会更加接近真实世界的物理模型。
当我们渲染3D图形的时候要呈现越接近真实的效果往往要考虑更多的参数因此所需的计算量也越大那我们就需要有更强的渲染能力比如更好的显卡更快的CPU和GPU并且也需要我们尽可能地优化计算的性能。
但是有很多时候我们需要在细节和性能上做出平衡和取舍。那性能优化的部分也是我们课程的重点我会在性能篇详细来讲。这节课我们就重点关注反射模型总结出完整的Phong反射模型就可以了。
## 要点总结
今天我们把环境光、平行光、点光源、聚光灯这四种光源整合并且在上节课讲的漫反射的基础上添加了镜面反射形成了完整的Phong反射模型。在这里我们实现的着色器代码能够结合四种光源的效果除了环境光外每种光源还可以设置多个。
在Phong反射模型中光照在物体上的最终效果由各个光源的性质参数和物体的表面材质共同决定。
Phong反射模型也只是真实世界的一种近似因为我们并没有考虑物体之间反射光的相互影响也没有考虑光线的遮挡。如果把这些因素考虑进去那我们的模型可以更接近真实世界了。
## 小试牛刀
我们知道,平行光、点光源和聚光灯是三种常见的方向光,但真实世界还有其他的方向光,比如探照灯,它是一种有范围的平行光,类似于聚光灯,但又不完全一样。你能给物体实现探照灯效果吗?
这里我先把需要用到的参数告诉你包括光源方向searchLightDirection、光源半径searchLightRadius、光源位置searchLightPosition、光照颜色searchLightColor。你可以用OGL实现探照灯效果然后把对应的着色器代码写在留言区。
<img src="https://static001.geekbang.org/resource/image/ce/6d/ce4bcyye0f4ac139625d96a2d5aeb06d.jpeg" alt="" title="探照灯示意图">
而且,探照灯的光照截面不一定是圆形,也可以是其他图形,比如三角形、菱形、正方形,你也可以试着让它支持不同的光照截面。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
## 源码
课程中完整代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/lights)
## 推荐阅读
[Phong反射模型简介](https://en.wikipedia.org/wiki/Phong_reflection_mode)

View File

@@ -0,0 +1,409 @@
<audio id="audio" title="25 | 如何用法线贴图模拟真实物体表面" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/e0/d89ec8e030e21c3d6bb96ffedee236e0.mp3"></audio>
你好,我是月影。
上节课我们讲了光照的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;
```
<img src="https://static001.geekbang.org/resource/image/c0/1f/c0241f80436bd66bb9b2ee37912e6a1f.jpeg" alt="">
现在这个立方体的表面是光滑的,如果我们想在立方体的表面贴上凹凸的花纹。我们可以加载一张**法线纹理**,这是一张偏蓝色调的纹理图片。
<img src="https://static001.geekbang.org/resource/image/8c/f7/8c13477872b6bc541ab1f9ec8017bbf7.jpeg" alt="">
```
const normalMap = await loadTexture('../assets/normal_map.png');
```
为什么这张纹理图片是偏蓝色调的呢?实际上,这张纹理图片保存的是几何体表面的每个像素的法向量数据。我们知道,正常情况下,光滑立方体每个面的法向量是固定的,如下图所示:
[<img src="https://static001.geekbang.org/resource/image/13/e4/13f742cafbf21d5afe6bef06a65ae3e4.jpeg" alt="">](http://www.mbsoftworks.sk/tutorials/opengl4/014-normals-diffuse-lighting/)
但如果表面有凹凸的花纹,那不同位置的法向量就会发生变化。在**切线空间**中因为法线都偏向于z轴也就是法向量偏向于(0,0,1),所以转换成的法线纹理就偏向于蓝色。如果我们根据花纹将每个点的法向量都保存下来,就会得到上面那张法线纹理的图片。
### 如何理解切线空间?
我刚才提到了一个词切线空间那什么是切线空间呢切线空间Tangent Space是一个特殊的坐标系它是由几何体顶点所在平面的uv坐标和法线构成的。
[<img src="https://static001.geekbang.org/resource/image/eb/91/ebaaafe6749e1ea9d47712d259f2c291.jpeg" alt="" title="切线空间">](https://math.stackexchange.com/questions/342211/difference-between-tangent-space-and-tangent-plane)
切线空间的三个轴,一般用 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;
}
```
而计算切线和副切线,要比计算法线复杂得多,不过,因为[数学推导过程](https://learnopengl.com/Advanced-Lighting/Normal-Mapping)比较复杂,我们只要记住结论就可以了。
<img src="https://static001.geekbang.org/resource/image/33/6b/336454df02a6f150eff17a0760c2616b.jpeg" alt="">
如上图和公式我们就可以通过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 &lt; 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 &lt; 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 &lt; MAX_LIGHT_COUNT; i++) {
vec3 dir = directionalLightDirection[i];
if(dir.x == 0.0 &amp;&amp; dir.y == 0.0 &amp;&amp; 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 &lt; MAX_LIGHT_COUNT; i++) {
vec3 decay = pointLightDecay[i];
if(decay.x == 0.0 &amp;&amp; decay.y == 0.0 &amp;&amp; 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 &lt; MAX_LIGHT_COUNT; i++) {
vec3 decay = spotLightDecay[i];
if(decay.x == 0.0 &amp;&amp; decay.y == 0.0 &amp;&amp; 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矩阵来计算对应的法线就行了。具体的计算方法就是把法线纹理贴图中提取的数据转换到[-11]区间然后左乘TBN矩阵并归一化。
然后我们将经过处理之后的法向量传给phongReflection计算光照就得到了法线贴图后的结果效果如下图
<img src="https://static001.geekbang.org/resource/image/f6/b7/f669899196e94d06b101bb5eeea69db7.gif" alt="">
到这里我们就实现了完整的法线贴图。法线贴图就是根据法线纹理中保存的法向量数据以及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了。不过在片元着色器中计算偏导数也有一定的性能开销所以各有利弊我们可以根据不同情况选择不同的方案。
## 法线贴图的应用
法线贴图的两种实现方式,我们都学会了。那法线贴图除了给几何体表面增加花纹以外,还可以用来增强物体细节,让物体看起来更加真实。比如说,在实现一个石块被变化的光源照亮效果的时候,我们就可以运用法线贴图技术,让石块的表面纹路细节显得非常的逼真。我把对应的片元着色器核心代码放在了下面,你可以利用今天学到的知识自己来实现一下。
<img src="https://static001.geekbang.org/resource/image/b2/5b/b28f5b31af8af0708e77e47e584a845b.gif" alt="">
```
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反射模型来实现光照效果吗你还可以思考一下应用法线贴图和不应用法线贴图绘制出来的墙有什么差别
<img src="https://static001.geekbang.org/resource/image/d1/3b/d107b4eeb30d46a37fa9ca85fa9b223b.jpeg" alt="">
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
## 源码
课程中完整示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/normal-maps)
## 推荐阅读
[Normal mapping](https://learnopengl.com/Advanced-Lighting/Normal-Mapping)

View File

@@ -0,0 +1,337 @@
<audio id="audio" title="26 | 如何绘制带宽度的曲线?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/54/716fa5a1835b0cdd15fd64091779c254.mp3"></audio>
你好,我是月影。
在可视化应用中我们经常需要绘制一些带有特定宽度的曲线。比如说在地理信息可视化中我们会使用曲线来描绘路径而在3D地球可视化中我们会使用曲线来描述飞线、轮廓线等等。
在Canvas2D中要绘制带宽度的曲线非常简单我们直接设置上下文对象的lineWidth属性就行了。但在WebGL中绘制带宽度的曲线是一个难点很多开发者都在这一步被难住过。
那今天我就来说说怎么用Canvas2D和WebGL分绘制曲线。我要特别强调一下我们讲的曲线指广义的曲线线段、折线和平滑曲线都包含在内。
## 如何用Canvas2D绘制带宽度的曲线
刚才我说了用Canvas2D绘制曲线非常简单。这是为什么呢因为Canvas2D提供了相应的API能够绘制出不同宽度、具有特定**连线方式**和**线帽形状**的曲线。
这句话怎么理解呢我们从两个关键词“连线方式lineJoin”和“线帽形状lineCap”入手理解。
我们知道曲线是由线段连接而成的两个线段中间转折的部分就是lineJoin。如果线宽只有一个像素那么连接处没有什么不同的形式就是直接连接。但如果线宽超过一个像素那么连接处的缺口就会有不同的填充方式而这些不同的填充方式就对应了不同的lineJoin。
比如说你可以看我给出的这张图上面就显示了四种不同的lineJoin。其中miter是尖角round是圆角bevel是斜角none是不添加lineJoin。很好理解我就不多说了
[<img src="https://static001.geekbang.org/resource/image/45/9c/458c1d4c49519c2ac897fe89397a0b9c.jpeg" alt="" title="4种不同的lineJoin">](https://mapserver.org/mapfile/symbology/construction.html)
说完了lineJoin那什么是lineCap呢lineCap就是指曲线头尾部的形状它有三种类型。第一种是square方形线帽它会在线段的头尾端延长线宽的一半。第二种round也叫圆弧线帽它会在头尾端延长一个半圆。第三种是butt就是不添加线帽。
[<img src="https://static001.geekbang.org/resource/image/60/c8/60cfd2020c014d88b7ac4b3b69e8e7c8.jpeg" alt="" title="3种不同的lineCap">](http://falcon80.com/HTMLCanvas/Attributes/lineCap.html)
理解了这两个关键词之后我们接着尝试一下怎么在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 &lt; 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所以我们绘制出来的曲线是尖角并且不带线帽的。
<img src="https://static001.geekbang.org/resource/image/d9/eb/d9ed91c48f6a44bcaa26dbe2yya4d5eb.jpeg" alt="">
其实我们还可以修改lineJoins和lineCap参数。比如我们将线帽设为圆的连接设为斜角。除此之外你还可以尝试不同的组合我就不再举例了。
```
ctx.strokeStyle = 'red';
drawPolyline(ctx, points, {lineWidth: 10, lineCap: 'round', lineJoin: 'bevel'});
```
<img src="https://static001.geekbang.org/resource/image/fe/0e/fe56yy2032acef591b6051328331d10e.jpeg" alt="">
除了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 &lt; 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});
```
<img src="https://static001.geekbang.org/resource/image/fe/0e/fe56yy2032acef591b6051328331d10e.jpeg" alt="">
你会发现这样渲染出来的图形它两侧的转角由于超过了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});
```
<img src="https://static001.geekbang.org/resource/image/ef/b3/ef7cdd7a0df26a237592d229bec226b3.jpeg" alt="">
你可能会问我们不能直接修改gl_PointSize来给折线设置宽度吗很遗憾这是不行的。因为gl_PointSize只能影响gl.POINTS图元的显示并不能对线段图元产生影响。
那我们该怎么让线的宽度大于1个像素呢
## 通过挤压(extrude)曲线绘制有宽度的曲线
我们可以用一种挤压Extrude曲线的技术通过将曲线的顶点沿法线方向向两侧移出让1个像素的曲线变宽。
那挤压曲线要怎么做呢?我们先看一张示意图:
<img src="https://static001.geekbang.org/resource/image/39/67/396bc27a64ec8cb608734b63cf44ee67.jpeg" alt="" title="挤压线段">
如上图所示黑色折线是原始的1个像素宽度的折线蓝色虚线组成的是我们最终要生成的带宽度曲线红色虚线是顶点移动的方向。因为折线两个端点的挤压只和一条线段的方向有关而转角处顶点的挤压和相邻两条线段的方向都有关所以顶点移动的方向我们要分两种情况讨论。
首先是折线的端点。假设线段的向量为x, y因为它移动方向和线段方向垂直所以我们只要沿法线方向移动它就可以了。根据垂直向量的点积为0我们很容易得出顶点的两个移动方向为-y, xy, -x。如下图所示
<img src="https://static001.geekbang.org/resource/image/48/12/482af6fd993dbf91f3635bddffdcd412.jpeg" alt="" title="折线端点挤压方向">
端点挤压方向确定了,接下来要确定转角的挤压方向了,我们还是看示意图。
<img src="https://static001.geekbang.org/resource/image/fa/e3/fa6b04dabfba6aaca02a66dde1d743e3.jpeg" alt="" title="转角的挤压方向示意图">
如上图我们假设有折线abcb是转角。我们延长ab就能得到一个单位向量v1反向延长bc可以得到另一个单位向量v2那么挤压方向就是向量v1+v2的方向以及相反的-(v1+v2)的方向。
现在我们得到了挤压方向,接下来就需要确定挤压向量的长度。
首先是折线端点的挤压长度它等于lineWidth的一半。而转角的挤压长度就比较复杂了我们需要再计算一下。
<img src="https://static001.geekbang.org/resource/image/d2/82/d2be1cd10fe7cdd0ab5611c13ab56882.jpeg" alt="" title="计算转角挤压长度示意图">
绿色这条辅助线应该等于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 &lt; 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 &lt; 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对象。
<img src="https://static001.geekbang.org/resource/image/1c/01/1c423ae467bce04f49a46a4fed376d01.jpeg" alt="" title="构建折线的顶点数据">
最后我们只要调用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});
```
我们最终渲染出来的效果如下图:
<img src="https://static001.geekbang.org/resource/image/7f/d4/7fb7607de6d1396ba92f53ac18e9acd4.jpeg" alt="">
这样我们就在WebGL中实现了与Canvas2D一样带宽度的曲线。
当然这里我们只实现了最基础的带宽度曲线它对应于Canvas2D中的lineJoin为miterlineCap为butt的曲线。不过想要实现lineJoins为bevel或roundlineCap为square或round的曲线也不会太困难。我们可以基于extrudePolyline函数对它进行扩展计算出相应属性下对应的顶点就行了。因为基本原理是一样的我就不详细说了我把扩展的任务留给你作为课后练习。
## 要点总结
这节课,我们讲了绘制带宽度曲线的方法。
首先在Canvas2D中绘制这样的曲线比较简单我们直接通过API设置lineWidth即可。而且Canvas2D还支持不同的lineJoin、lineCap设置以及miterLimit设置。
在WebGL中绘制带宽度的曲线则比较麻烦因为没有现成的API可以使用。这个时候我们可以使用挤压曲线的技术来得到带宽度的曲线挤压曲线的具体步骤可以总结为三步
1. 确定端点和转角的挤压方向,端点可以沿线段的法线挤压,转角则通过两条线段延长线的单位向量求和的方式获得。
1. 确定端点和转角挤压的长度端点两个方向的挤压长度是线宽lineWidth的一半。求转角挤压长度的时候我们要先计算方向向量和线段法线的余弦然后将线宽lineWidth的一半除以我们计算出的余弦值。
1. 由步骤1、2计算出顶点后我们构建三角网格化的几何体顶点数据然后将Geometry对象返回。
这样我们就可以用WebGL绘制出有宽度的曲线了。
## 小试牛刀
1. 你能修改extrudePolyline函数让它支持lineCap为square和round吗或者让它支持lineJoin为round吗
1. 我想让你试着修改一下extrudePolyline函数让它支持lineJoin为bevel以及miterLimit。并且当lineJoin为miter的时候如果转角挤压长度超过了miterLimit我们就按照bevel处理向外的挤压。
那通过今天的学习,你是不是已经学会绘制带宽度曲线的方法。那不妨就把这节课分享给你的朋友,也帮助他解决这个难题吧。好了,今天的内容就到这里了,我们下节课再见
## 源码
课程完整示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/polyline-curve)

View File

@@ -0,0 +1,353 @@
<audio id="audio" title="27 | 案例如何实现简单的3D可视化图表" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/2d/746f5738218ee0fd28dbc901b128fb2d.mp3"></audio>
你好,我是月影。
学了这么多图形学的基础知识和WebGL的视觉呈现技术你一定已经迫不及待地想要开始实战了吧今天我带你完成一个小型的可视化项目带你体会一下可视化开发的全过程。也正好借此机会复习一下我们前面学过的全部知识。
这节课,我们要带你完成一个**GitHub贡献图表的可视化作品**。GitHub贡献图表是一个统计表它统计了我们在GitHub中提交开源项目代码的次数。我们可以在GitHub账号信息的个人详情页中找到它。
下图中的红框部分就是我的贡献图表。你会看到GitHub默认的贡献图表可视化展现是二维的那我们要做的就是把它改造为简单的动态3D柱状图表。
<img src="https://static001.geekbang.org/resource/image/4a/0b/4a44441b2431ce98d6139b89ae16f70b.jpg" alt="" title="GitHub默认的贡献图表可视化展现示意图">
## 第一步:准备要展现的数据
想要实现可视化图表第一步就是准备数据。GitHub上有第三方API可以获得指定用户的GitHub贡献数据具体可以看[这个项目](https://github.com/sallar/github-contributions-api)。
通过API我们可以事先保存好一份JSON格式的数据具体的格式和内容大致如下
```
// github_contributions_akira-cn.json
{
&quot;contributions&quot;: [
{
&quot;date&quot;: &quot;2020-06-12&quot;,
&quot;count&quot;: 1,
&quot;color&quot;:&quot;#c6e48b&quot;,
},
...
],
}
```
从这份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) =&gt; {
o.date = new Date(o.date.replace(/-/g, '/'));
return o;
});
}
// 要拿到 toData 日期之前大约一年的数据52周
let start = 0,
end = cache.length;
// 用二分法查找
while(start &lt; end - 1) {
const mid = Math.floor(0.5 * (start + end));
const {date} = cache[mid];
if(date &lt;= toDate) end = mid;
else start = mid;
}
// 获得对应的一年左右的数据
let day;
if(end &gt;= 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 &lt; len) {
// 日期超过了数据范围,补齐数据
const pad = new Array(len - ret.length).fill({count: 0, color: '#ebedf0'});
ret.push(...pad);
}
return ret;
}
```
这个函数的逻辑是先从JSON文件中读取数据并缓存起来然后传入对应的日期对象获取该日期之前大约一年的数据准确来说是该日期的前52周数据再加上该日期当前周直到该日期为止的数据公式为 7*52 + day + 1
这样,我们就准备好了要用来展现的数据。
## 第二步用SpriteJS渲染数据、完成绘图
有了数据之后接下来我们就要把数据渲染出来完成绘图。这里我们要用到一个新的JavaScript库SpriteJS来绘制。
既然如此我们先来熟悉一下SpriteJS库。
[SpriteJS](https://spritejs.org/#/)是基于WebGL的图形库也是我设计和维护的开源可视化图形渲染引擎项目。它是一个支持树状元素结构的渲染库。也就是说它和我们前端操作DOM类似通过将元素一一添加到渲染树上就可以完成最终的渲染。所以在后续的课程中我们也会更多地用到它。
我们要用到的是SpriteJS的3D部分它是基于我们熟悉的OGL库实现的。那我们为什么不直接用OGL库呢这是因为SpriteJS在OGL的基础上对几何体元素进行了类似DOM元素的封装。这样我们创建几何体元素就可以像操作DOM一样方便了直接用d3库的selection子模块来操作就可以了。
### 1. 创建Scene对象
像DOM有documentElement作为根元素一样SpriteJS也有根元素。SpriteJS的根元素是一个Scene对象对应一个DOM元素作为容器。更形象点来说我们可以把Scene理解为一个“场景”。那SpriteJS中渲染图形都要在这个“场景”中进行。
接下来我们就创建一个Scene对象代码如下
```
const container = document.getElementById('stage');
const scene = new Scene({
container,
displayRatio: 2,
});
```
创建Scene对象我们需要两个参数。一个参数是container它是一个HTML元素在这里是一个id为stage的元素这个元素会作为SpriteJS的容器元素之后SpriteJS会在这个元素上创建Canvas子元素。
第二个参数是displayRatio这个参数是用来设置显示分辨率的。你应该还记得在讲Canvas绘图的时候我们提到过为了让绘制出来的图形能够适配不同的显示设备我们要把Canvas的像素宽高和CSS样式宽高设置成不同的值。所以这里我们把displayRatio设为2就可以让像素宽高是CSS样式宽高的2倍对于一些像素密度为2的设备如iPhone的屏幕这么设置才不会让画布上绘制的图片、文字变得模糊。
### 2. 创建Layer对象
有了scene对象我们再创建一个或多个Layer对象也可以理解为是一个或者多个“图层”。在SpriteJS中一个Layer对象就对应于一个Canvas画布。
```
const layer = scene.layer3d('fglayer', {
camera: {
fov: 35,
},
});
layer.camera.attributes.pos = [2, 6, 9];
layer.camera.lookAt([0, 0, 0]);
```
如上面代码所示我们通过调用scene.layer3d方法就可以在scene对象上创建了一个3DWebGL上下文的Canvas画布。而且这里我们把相机的视角设置为35度坐标位置为2, 6, 9相机朝向坐标原点。
### 3. 将数据转换成柱状元素
接着,我们就要把数据转换成画布上的长方体元素。我们可以借助[d3-selection](https://github.com/d3/d3-selection)d3是一个数据驱动文档的模型d3-selection能够通过数据操作文档树添加元素节点。当然在使用d3-selection添加元素前我们要先创建用来3D展示的WebGL程序。
因为SpriteJS提供了一些预置的着色器比如shaders.GEOMETRY着色器就是默认支持phong反射模型的一组着色器我们直接调用它就可以了。
```
const program = layer.createProgram({
vertex: shaders.GEOMETRY.vertex,
fragment: shaders.GEOMETRY.fragment,
});
```
创建好WebGL程序之后我们就可以获取数据用数据来操作文档树了。
```
const dataset = await getData();
const max = d3.max(dataset, (a) =&gt; {
return a.count;
});
/* globals d3 */
const selection = d3.select(layer);
const chart = selection.selectAll('cube')
.data(dataset)
.enter()
.append(() =&gt; {
return new Cube(program);
})
.attr('width', 0.14)
.attr('depth', 0.14)
.attr('height', 1)
.attr('scaleY', (d) =&gt; {
return d.count / max;
})
.attr('pos', (d, i) =&gt; {
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) =&gt; {
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柱状图了效果如下
<img src="https://static001.geekbang.org/resource/image/0c/a6/0c7a265e05d79336fc5a045dd6b3c0a6.gif" alt="">
## 第三步:补充细节,实现更好的视觉效果
现在这个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);
```
<img src="https://static001.geekbang.org/resource/image/0e/fb/0e9764123667yy18329ef01a4a6771fb.gif" alt="">
除此之外,我们还可以给柱状图增加一个底座,代码和效果图如下:
```
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);
```
<img src="https://static001.geekbang.org/resource/image/b1/ce/b1c0dec29b6e6b16c4d86e786f7d12ce.gif" alt="">
上面的代码不复杂,我想重点解释其中两处。首先是片元着色器代码,我们使用了根据纹理坐标来实现重复图案的技术。这个方法和我们[第11节课](https://time.geekbang.org/column/article/262330)说的思路完全一样,如果你对这个方法感到陌生了,可以回到前面复习一下。
其次我们将底座的高度设置为0.1y的值本来应该是-0.1的一半,也就是-0.05,但是我们设置为了-0.049。少了0.001是为了让上层的柱状图稍微“嵌入”到底座里从而避免因为底座上部和柱状图底部的z坐标一样导致渲染的时候由于次序问题出现闪烁这个问题在图形学术语里面有一个名字叫做z-fighting。
<img src="https://static001.geekbang.org/resource/image/9d/f8/9da3bdd37c5e269b551b63b8ac7510f8.gif" alt="" title="z-fighting 现象">
z-fighting是3D绘图中的一个常见问题所以我再多解释一下。在WebGL中绘制3D物体一般我们开启了深度检测之后引擎会自动计算3D物体的深度让离观察者很近的物体面把离观察者比较远和背对着观察者的物体面遮挡住。那具体是怎么遮挡的呢其实是根据物体在相机空间中的z坐标来判断的。
但有一种特殊情况就是两个面的z坐标相同又有重叠的部分。这时候引擎就可能一会儿先渲染A面过一会儿又先去渲染B面这样渲染出来的内容就出现了“闪烁”现象这就是z-fighting。
<img src="https://static001.geekbang.org/resource/image/37/8c/3718a4e779004624f44ce952923c348c.jpg" alt="" title="如果A和B深度z坐标相同那么A、B重叠部分渲染次序可能每次不同从而产生z-fighting">
z-fighting有很多解决方法比如可以人为指定一下几何体渲染的次序或者就是让它们的坐标不要完全相同在上面的例子里我们就采用了让坐标不完全相同的处理办法。
最后,为了让实现出来的图形更有趣,我们再增加一个过渡动画,让柱状图的高度从不显示,到慢慢显示出来。
<img src="https://static001.geekbang.org/resource/image/88/08/887d3e8b4e356b9139934eee7bb70c08.gif" alt="">
要实现这个效果我们需要稍微修改一下d3.selection的代码。
```
const chart = selection.selectAll('cube')
.data(dataset)
.enter()
.append(() =&gt; {
return new Cube(program);
})
.attr('width', 0.14)
.attr('depth', 0.14)
.attr('height', 1)
.attr('scaleY', 0.001)
.attr('pos', (d, i) =&gt; {
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) =&gt; {
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) =&gt; {
return linear(d.count);
})
.attr('y', (d, i) =&gt; {
return 0.5 * linear(d.count);
});
```
到这里呢,我们就实现了我们想要实现的所有效果了。
## 要点总结
这节课我们一起实现了3D动态的GitHub贡献图表整个实现过程可以总结为两步。
第一步是处理数据我们可以通过API获取JSON数据然后得到我们想要的数据格式。第二步是渲染数据今天我们是使用SpriteJS来渲染的它的API类似于DOM对d3非常友好。所以我们可以直接使用d3-selection以数据驱动文档的方式就可以构建几何体元素。
并且为了更好地展现数据之间的变换关系我们根据数据创建了Cube元素并将它们渲染了出来。而且我们还给实现的柱状元素设置了光照、实现了过渡动画算是实现了一个比较完整的可视化效果。
此外我们还要注意在实现过渡动画的过程中很容易出现z-fighting问题也就是我们实现的元素由于次序问题在渲染的时候出现闪烁。这个问题在可视化中非常常见不过我们通过设置渲染次序或者避免坐标相同就可以避免。
到这里我们视觉进阶篇的内容就全部讲完了。这一篇我从实现简单的动画讲到了3D物体的绘制、旋转、移动以及给它们添加光照效果、法线贴图让它们能更贴近真实的物体。
说实话,这一篇的内容单看真的不简单。但你认真看了会发现,所有的知识都是环环相扣的,只要有了前几篇的基础,我们再来学肯定可以学会。为了帮助你梳理这一篇的内容,我总结了一张知识脑图放在了下面,你可以看看。
<img src="https://static001.geekbang.org/resource/image/fd/65/fd8eb76869dc873a816f92ddbd76c265.jpg" alt="">
## 小试牛刀
我们今天讲的这个例子你学会了吗你可以用自己的GitHub贡献数据来实现同样的图表也可以稍微修改一下它的样式比如采用不同的颜色、不同的光照效果等等。
另外,课程中的例子是默认获取最近一年到当天的数据,你也可以扩展一下功能,让这个图表可以设置日期范围,根据日期范围来呈现数据。
如果你的GitHub贡献数据不是很多也可以去找相似平台上的数据来实现类似的图表。
今天的实战项目有没有让你体会到可视化的魅力呢?那就快把它分享出去吧!我们下节课再见!
## 源码
[实现3D可视化图表详细代码](https://github.com/akira-cn/graphics/tree/master/github-contributions)
## 推荐阅读
[1] [SpriteJS官网](https://spritejs.org)<br>
[2] [d3-api](https://github.com/d3/d3/blob/master/API.md)

View File

@@ -0,0 +1,121 @@
<audio id="audio" title="开篇词 | 不写网页的前端工程师,还能干什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/fc/aea446c428188ebb5e488f1d555290fc.mp3"></audio>
你好,我是月影。
可能你知道我是因为360奇舞团而我是奇虎360奇舞团的团长。我们奇舞团是360的大前端团队主要工作是支持公司的一部分业务包括可视化大屏、技术中台的开发。
把团长这层身份抛开我还是一个有着15年工作经验的前端工程师其中有很多年在做JavaScript开发。如果你也做过JavaScript开发我的这本《JavaScript王者归来》你很可能读过。
作为前端工程师,很多人的主要工作就是和网页打交道。那扪心自问一下,写了这么多网页之后,你是不是也遇到过前端职业发展的天花板,想要做些尝试或者突破呢?如果是的话,我建议你试试可视化。
可视化是前端领域中,一个几乎不用写网页的特殊分支,也是最近这几年我的主要研究方向。通过对图形学和渲染引擎底层的深入研究,我作为核心开发者,开发出了跨平台的开源图形系统[SpriteJS](http://spritejs.org)。同时,这个过程,也让我对可视化领域有了更多自己的想法。
所以今天我想和你聊一聊可视化,说说前端工程师不写网页,到底还能干什么。
## 前端工程师为什么要学可视化?
首先,我们来说说可视化的两个重要优势。借助它们,你就能明白可视化到底能干什么。
**第一现在很多C端或者B端的互联网产品都离不开可视化**
C端最著名的例子就是淘宝“双十一”购物节的可视化大屏。B端产品的例子那就更多了许多中后台的管理系统都会用统计图表来显示系统运行的各种状态。另外很多企业级应用中的态势感知和指挥调度大屏也是可视化非常常见的应用以及国家大力推动的智慧城市、智慧社区、智慧生活等等。如果你有关注过就会发现可视化在其中都有着非常广泛的应用。
也正因为如此,行业内对可视化工程师的需求也越来越大,许多一线的互联网公司都有可视化相关的招聘需求,但是相关的专业人才却寥寥无几。
**第二可视化能实现很多传统Web网页无法实现的效果。**
在很多人看来,可视化就是绘制各种图表,学习可视化就是学习如何画饼图、折线图、柱状图、玫瑰图这类图表。如果你也这么认为,那你可能还没有真正理解可视化的本质。实际上,真正的可视化领域,和你想得还真不太一样。
那可视化的本质到底是什么呢?我们先来看一个真实的例子。
我们奇舞团有一个传统,那就是每年年会时,我会给大家现场写一个抽奖程序,所有在场的人共同审查代码,确认没有问题后,开启这一年愉快的年会抽奖活动。
在最近的一次年会上,我写了一个比较另类的抽奖程序,怎么另类呢?
我们知道一般的抽奖程序如果用JavaScript代码来实现其核心无非就是产生一个随机数然后用它来判断究竟谁该获奖。这个程序当然最终要通过CPU来执行。但是我写的这个抽奖程序却是使用[GPU](https://github.com/akira-cn/FE_You_dont_know/issues/27)来抽奖。我们知道CPU是运算单元GPU是图形处理单元那用GPU怎么来做抽奖这件事儿呢
我把这个抽奖程序最终实现的效果放在了下面。你会看到这个小小的画布上一共分了100块每一块代表一个员工。第一次抽奖后代表中奖者的方块会变色之后再抽奖颜色就会不断叠加。这个程序不仅能保证每个人中奖的概率是公平的还能保证一个已经中奖的人不会重复中奖。
<img src="https://static001.geekbang.org/resource/image/3b/d5/3b7eb5d69829f9e24b0e5b7abfc8ffd5.gif" alt="">
你可能会觉得这个抽奖好像没有什么用处啊。其实并不是我们要知道这个程序在理论上可以同时对上百万人我们可以用一个1024*1024的画布用一个像素点表示一个区块进行抽奖虽然很少有对上百万人同时抽奖的场景但是很多程序比如一些AI的程序其实核心原理类似于这样的抽奖。
在这里我是用了GPU的能力通过渲染图形来完成的抽奖这实际上就用到了可视化相关的核心技术。所以你看可视化可不只是在绘制图表图表只是可视化工程师日常工作产出的一部分。而就像上面这个例子一样**可视化更重要的是利用WebGL这样的图形系统来更高效地实现更多有趣的图形**。尤其是当你在写一个可视化的项目要在大屏上用浏览器渲染数以万计的元素时也许WebGL或者GPU就是目前唯一可行的方案。
总的来说通过研究可视化你能够比较深入地掌握图形学和WebGL技术。而这些技术的应用呢并不局限于可视化领域它们在游戏、AI等其他技术领域也有广泛的应用场景。
所以就算你并不想成为一名可视化工程师但学习相关的图形、视觉呈现的原理和方法也能最大化地丰富你的知识面拓宽你的技术成长路线让你的技术天花板变得更高。更详细点来说像视觉呈现技术中也有涉及高级CSS原理的部分所以如果你学会了可视化也会对你的CSS技能有很大的启发和提升。
## 如何成为一名可视化工程师?
既然可视化这么有用,那想要成为一名优秀的可视化工程师,我们该怎么做呢?
我觉得,一名优秀的可视化工程师,最应当具备的能力就是要根据产品经理和设计师的想法,实现真正贴合用户需求的、灵活多变的、具有视觉震撼力,并且充满创意的优秀可视化作品。
这句话说起来简单但是要做到绝不是会使用某个库、会调用某个API就能实现的。
比如说,现在我们要把一个图表的线条样式,从实线段改成虚线段,那我们就可能不能使用某个现有的图表库来完成。这是因为,有可能这个图表库中并没有提供参数支持线条的虚线。这时候,我们要么放弃这个需求,要么就得换图表库或者自己去修改图表库的源代码。
如果我们不放弃,想要自己去修改图表库的源代码的话,其实会遇到更多的问题。因为,这些图表应用不仅业务代码相当复杂,图表库本身的源代码也很复杂,所以,不管是修改代码还是后期维护,我们都会面临很多复杂的问题。
再比如说我团队的一名同学最近在实现一个3D可视化大屏需要渲染大约70万个元素处理各种视觉和交互细节。这可不是调用某某库的某个组件就能轻松实现的在技术实现的背后有着非常深的图形学、物理学和数学知识。
因此,想要成为一名可视化工程师,**我们不能停留在可视化现有的库、框架和工具的使用层面上,必须深入技术栈的底层,充分理解数据,掌握视觉呈现相关的技术和基本原理。打好基础之后,再系统地学习可视化领域的方法论,才能提高解决问题的能力。**
也正因为如此,我专门打造了这门课,目的就是帮你打好底层基础,建立可视化知识体系,助力你成为合格的可视化工程师。
## 如何快速掌握可视化?
首先,想要学好可视化,我们就必须要了解可视化的体系都有什么。这里我画了一张知识结构图,你可以看看。
<img src="https://static001.geekbang.org/resource/image/d6/4a/d67be0769ae01eda404138a042a8344a.jpg" alt="">
从图中你可以看到,可视化涉及的内容非常多。光是数据就包括数据分析、数据建模和设计语言,它们每一个都能单独开一门课程了。可想而知,可视化涉及的知识有多广。既然如此,我们该如何从这么多的内容中,快速掌握可视化呢?**答案是要有重点地学习。**
那如何有重点地学习呢?我们下面分别来分析。
首先,可视化的视觉主要涉及四个部分,分别是**理论基础**、**图形系统**、**图形库**和**工具**。看起来内容很多,但我们没有必要全都深入学习。而是应该把重点放在理论基础上,**因为随着技术的发展,工具会改变,而基础不会。**视觉部分的理论基础就是数学和图形学,也就是说,我们必须要学会如何运用数学和图形学知识,来绘制各种各样的几何图形,这样才能实现更加酷炫的视觉效果。
数据这一块呢,虽然相对比较简单,但是内容也不少。如果我们要深入学习细节的话,压力太大,也很难坚持下去。好在,可视化工程师对数据的理解,会随着业务的深入而逐步加深。所以,在数据部分,我们只要理解解决问题的整体构思和方法论,知道如何从一个可视化工程师的角度去理解和运用数据,就已经足够为我们未来的深入研究,打开一扇大门了。
最后谈可视化离不开性能性能往往决定了可视化项目最终所能呈现的整体感官效果。怎么理解呢举个例子当你想要通过一段代码来实现非常具有视觉冲击力的效果时却发现即使GPU和CPU都以最大功率运转了还是没能把你构思的画面在屏幕上流畅地显示出来。这是为什么呢
这很大可能是因为性能没有达到最优。也许是你的技术选型和代码实现在渲染方面遇到了瓶颈;也许是你过于关注渲染本身,而忽视了计算;也有可能是核心算法有改进空间。有时候,我们花大力气去调优渲染机制,其实不如换一种更加合理的算法取得的收益更大。上面这些呢,都有可能是导致性能不佳的元凶。因此,在性能方面,我们应该学习的,是如何快速找到合理的性能优化点。
好了,学习可视化的重点我们说完了,那到底该怎么学呢?我结合这些重点,梳理了一张可视化的学习路径图。接下来,我就结合这个路径图来说说,我们的课程到底是怎么设计的。
<img src="https://static001.geekbang.org/resource/image/0b/d4/0b71fbc404c3125585e609a43c64d9d4.jpg" alt="">
## 我们的课程是如何设计的?
我把整个课程分为了五个部分,分别是图形基础、数学基础、视觉呈现、性能优化和数据驱动。下面,我们来详细说说。
**第一部分,图形基础**。首先我会带你熟悉HTML/CSS、SVG、Canvas2D和WebGL这四种图形系统并会结合一些简单的实际案例帮助你全面掌握它们的基本用法、原则、优点和局限性。通过这部分的内容你能对图形系统有一个大体的认知并且掌握一些必须的绘图技巧从而能够更好地学习后续的课程。
**第二部分,数学基础**。在这一部分里,我希望通过对向量和矩阵运算、参数方程、三角剖分和仿射变换等内容的深入讲解以及综合运用,来帮助你建立一套通用的数学知识体系,能适用于所有图形系统,以此来解决可视化图形呈现中的大部分问题。
**第三部分,视觉呈现**。视觉呈现效果是衡量可视化效果的重要指标因此实现各种视觉效果的能力可以说是可视化工程师的核心竞争力。视觉呈现也是可视化中非常重要的内容那这一部分我会分两个阶段来讲在基础阶段我们主要讨论如何以像素化的方式处理图案来实现各种细节效果在高级阶段我们讨论动画、3D和交互等更深入的话题。并且我还会借助美颜、图片处理和视觉特效等实际例子来教你怎样更好地应用数学和图形学知识帮你全面提升视觉呈现的能力。
**第四部分,性能优化**。这一部分我会讲解WebGL渲染复杂的2D、3D模型的方法让你了解可视化高性能渲染的技术思路比较轻松地找到可视化应用中的性能瓶颈并着手优化。再结合我总结出的一些成熟的方法论你就能在实现可视化项目的时候解决大规模数据批量渲染的性能瓶颈问题来实现更酷炫的视觉效果了。
**最后一部分,我们将重点讨论数据驱动**。通常来说数据的格式和量级决定了图形系统的选择和渲染方式。一个成熟的可视化系统应该包括数据引擎和渲染引擎两部分。所以对于可视化而言数据和渲染同样重要。那么在这一部分中我会结合3D柱状图、3D层级结构图、3D音乐可视化等案例讲解数据处理的技巧真正将数据和视觉呈现结合起来实现具有科技感的3D可视化大屏效果并最终形成完整的可视化解决方案。
以上,就是这门课我想分享给你的全部内容了。总的来说,我希望通过这门课的学习和讨论,你能真正系统地掌握可视化开发的原理和方法,理解如何处理数据,学会各种视觉呈现方式,最终创建出自己的可视化工具集,并且用它来更高效地解决可视化开发中的问题。
其实到这儿,课程的核心内容就说完了。不过,在学习上,我还想再啰嗦几句,要掌握这些内容,肯定需要你付出非常多的耐心和毅力,并且保持积极的思考。
课程中难免有部分内容比较抽象和枯燥,这也正是我们最容易放弃的地方。所以,在课程中,我也会尽量用一些比较浅显而有趣的例子把内容串起来。同时,为了帮助你理解,我会添加大量的代码。我希望你能真正在浏览器中运行这些代码,如果遇到问题,也非常欢迎你在留言区和我一起讨论。
最后,我是一名工程师,喜欢写代码,我认为很多道理通过代码来讲是最直接的。如果你也和我一样,那就快点加入可视化的学习,和我一起享受“创造世界”的乐趣吧!
## 源代码
[抽奖程序开源代码及在线演示.](https://github.com/akira-cn/FE_You_dont_know/issues/27)
## 推荐阅读
[1] [Canvas API的官方文档.](https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API)
[2] [SVG的官方文档.](https://developer.mozilla.org/zh-CN/docs/Web/SVG)
[3] [WebGL API的官方文档.](https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API)

View File

@@ -0,0 +1,94 @@
<audio id="audio" title="预习 | Web前端与可视化到底有什么区别" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/e6/4cf8802e1194711a4f847283f31549e6.mp3"></audio>
你好我是月影。在课程正式开始之前我想先和你聊聊可视化是什么Web前端和可视化的区别以及可视化领域中非常重要的工具。了解了这些基本的东西对我们的学习是非常有帮助的。
前段时间因为新型冠状病毒,我们每天都在关注疫情信息。不知道你有没有注意过这些疫情信息的展示方式。
[<img src="https://static001.geekbang.org/resource/image/5a/d8/5ad44fe26f7eb1b2132a041a2e62a2d8.png" alt="" title="来源:北京大学可视化与可视分析实验室">](https://vis.ucloud365.com/ncov/china_stat/#/)
我们看到的疫情图大概都会通过上面这个信息图的样子展示出来。这种信息图与普通的网页看上去差别非常大我们没办法用传统的Web开发技术实现这样的“网页”。没错这是一个与传统Web开发完全不一样的领域叫做**数据可视化**Data Visualization
其实,除了“疫情地图”之外,我们平时见到的很多东西,都是通过数据可视化来实现的。比如,每年淘宝“双十一”的可视化数据大屏、各种平台的年度账单等等。
那你可能要问了,可视化到底是什么呢?
如果要给可视化下一个定义,我觉得应该是这样的:**可视化是将数据组织成易于为人所理解和认知的结构,然后用图形的方式形象地呈现出来的理论、方法和技术**。实现可视化有两个关键要素,一个是数据,另一个是图形。如果要考虑在计算机上呈现,那还要加上交互。
## Web前端与可视化有什么区别
据我所知很多同学在工作中因为产品需求要呈现图表而不知不觉从Web开发进入了可视化领域。但因为不了解它们之间的核心区别或多或少都会遇到一些棘手的问题。
那可视化和Web前端究竟有什么区别呢
我们先从工作内容来看前端工程师主要负责处理内容呈现和用户交互。可视化的数据呈现尤其是在Web端的呈现也属于前端工程师的范畴。但是与传统的前端开发相比可视化开发的工具、思路和方法又和Web有着比较大的区别。
**首先是技术栈的不同**。Web开发主要以HTML来描述结构以CSS来描述表现以JavaScript来描述行为。而可视化则较少涉及HTML和CSS它更多地要同浏览器的Canvas、SVG、WebGL等其他图形API打交道。这是因为Web开发以呈现块状内容为主所以HTML是更合适的技术。而可视化开发因为需要呈现各种各样的形状、结构所以形状更丰富的SVG以及更底层的Canvas2D和WebGL就是更合适的技术了。<br>
<img src="https://static001.geekbang.org/resource/image/92/96/928189f29b8863cf55cfbdcc3ec84496.jpg" alt="" title="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或者类似的[Chartist](https://github.com/gionkunz/chartist-js)、[Chart.js](https://github.com/chartjs/Chart.js)等等,它们都属于图表库。
<img src="https://static001.geekbang.org/resource/image/93/45/9326dac973df3afffdb6012bb75f2b45.jpg" alt="">
如果要绘制更加复杂的地图比如一座城市的交通线路和建筑物三维模型或者一个园区的立体建筑模型等等我们可能要依赖专业的GIS地图库。社区中比较成熟的GIS库也不少比较常见的像[Mapbox](https://www.mapbox.com/)、[Leaflet](https://leafletjs.com/)、[Deck.gl](http://deck.gl/)、[CesiumJS](https://cesium.com/cesiumjs/)等等。
<img src="https://static001.geekbang.org/resource/image/0e/d4/0ecc3b4d55b964205bba54af390434d4.jpg" alt="" title="Deck.gl绘制的3D地图效果">
如果要**绘制其他更灵活的图形、图像或者物理模型**,我们常用的一些图表库就不一定能完成了。这个时候,我们可以用[ThreeJS](https://threejs.org/)、[SpriteJS](https://spritejs.org)这样比较通用的渲染库实际上图表库或GIS地图库本身底层渲染也基于这些渲染库。我们可以选择通用的图形库比如2D渲染可以选择SpriteJS3D渲染可以选择ThreeJS、BabylonJS以及SpriteJS3D扩展等等。
<img src="https://static001.geekbang.org/resource/image/f9/26/f946c2230179ce2f3b13f42c8d719126.jpg" alt="" title="SpriteJS">
<img src="https://static001.geekbang.org/resource/image/59/6a/59864101b60fff4da568e56f0542a66a.jpg" alt="" title="ThreeJS">
除了这些库之外,还有一类比较特殊的库,比如[D3.js](https://d3js.org/),它属于数据驱动框架。那什么是数据驱动框架呢?这是一种特殊的库,它们更**专注于处理数据的组织形式**而将数据呈现交给更底层的图形系统DOM、SVG、Canvas或通用图形库SpriteJS、ThreeJS去完成。
D3.js与图表库一样都能完成可视化项目中的各种图表展现但是它们之间也有区别。下面我来说说它们各自的优势和劣势。
ECharts等大部分图表库会提供封装好的图表类型我们只需要简单配置一下参数就可以使用。但正因为如此图表能够表现的形式也会被预设的图表类型和封装好的参数所固定了。如果我们想做一些非常个性化的视觉呈现形式用图表库来做相对就比较困难。而D3.js因为只关注数据的组织形式将具体的渲染交给底层去做所以更加灵活扩展起来也很方便。但相对地就不像其他的图表库一样拥有完整的封装了使用的门槛也就相对高一些。
<img src="https://static001.geekbang.org/resource/image/d2/38/d20b5c245c69520d3a935a0b2d5d8238.jpg" alt="" title="用d3绘制的等高线">
D3.js是可视化领域一个很重要的库关于它的具体运作机制比较复杂我们会在数据篇里详细来讲。
## 要点总结
这一节课我们重点讲了Web前端和可视化的区别和共同之处。
区别主要有两方面首先是技术栈的不同。Web开发主要会用到HTML和CSS而可视化则较少涉及HTML和CSS它更多地要同浏览器的Canvas、SVG、WebGL等其他图形API打交道。其次Web开发着重于处理普通的文本和多媒体信息渲染普通的、易于阅读的文本和多媒体内容而可视化开发则着重于处理结构化数据有时需要深入渲染引擎层从而控制细节让浏览器渲染出各种相对复杂的图表和图形元素。
不过如果你原本负责前端开发想要学习可视化门槛并不高因为它们之间有很多共通的方法论而且都依赖于JavaScript。并且可视化也有很多成熟的工具和库可以供我们使用。
其实今天说了这么多我就是想告诉你可视化虽然涉及的内容非常多其核心技术与Web开发的前端工程师的侧重点有所不同但是其核心思想和方法论与Web前端是相通的很多技术也是相辅相成、相互启发的。
因此前端工程师学习可视化并没有太大的困难甚至学习这些和可视化相关的图形学理论和技术也能够提升Web开发的前端技术。
## 一起聊一聊
你为什么想要学习可视化呢?你觉得在学习的过程中有什么难点?
欢迎你在留言区把你的想法和疑惑分享出来,我会尽力为你解答。如果这一节课对你有帮助,也欢迎你把它分享给你的朋友们!