This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

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)