mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-12-28 19:16:02 +08:00
mod
This commit is contained in:
433
极客时间专栏/跟月影学可视化/视觉高级篇/18 | 如何生成简单动画让图形动起来?.md
Normal file
433
极客时间专栏/跟月影学可视化/视觉高级篇/18 | 如何生成简单动画让图形动起来?.md
Normal 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角度。这样就能实现蓝色方块旋转的动画。具体的代码和效果如下所示。
|
||||
|
||||
```
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<style>
|
||||
.block {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
top: 100px;
|
||||
left: 100px;
|
||||
transform-origin: 50% 50%;
|
||||
position: absolute;
|
||||
background: blue;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="block"></div>
|
||||
<script>
|
||||
const block = document.querySelector('.block');
|
||||
let rotation = 0;
|
||||
requestAnimationFrame(function update() {
|
||||
block.style.transform = `rotate(${rotation++}deg)`;
|
||||
requestAnimationFrame(update);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
<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 >= this.iterations;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们实现一个Animator类,用来真正控制动画过程。
|
||||
|
||||
```
|
||||
import {Timing} from './timing.js';
|
||||
|
||||
export class Animator {
|
||||
constructor({duration, iterations}) {
|
||||
this.timing = {duration, iterations};
|
||||
}
|
||||
|
||||
animate(target, update) {
|
||||
let frameIndex = 0;
|
||||
const timing = new Timing(this.timing);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function next() {
|
||||
if(update({target, frameIndex, timing}) !== false && !timing.isFinished) {
|
||||
requestAnimationFrame(next);
|
||||
} else {
|
||||
resolve(timing);
|
||||
}
|
||||
frameIndex++;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Animator构造器接受{duration, iterations}作为参数,它有一个animate方法,会在执行时创建一个timing对象,然后通过执行update({target, frameIndex, timing})更新动画,并且会返回一个promise对象。这样,在动画结束时,resolve这个promise,我们就能够很方便地实现连续动画了。
|
||||
|
||||
接下来,你可以想一个动画效果,来试验一下这个模型的效果。比如说,我们可以用Animator实现四个方块的轮换转动,让每个方块转动的周期是1秒,一共旋转1.5个周期(即540度)。代码和效果如下所示。
|
||||
|
||||
```
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 300px;
|
||||
}
|
||||
.block {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 20px;
|
||||
flex-shrink: 0;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.block:nth-child(1) {background: red;}
|
||||
.block:nth-child(2) {background: blue;}
|
||||
.block:nth-child(3) {background: green;}
|
||||
.block:nth-child(4) {background: orange;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="block"></div>
|
||||
<div class="block"></div>
|
||||
<div class="block"></div>
|
||||
<div class="block"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import {Animator} from '../common/lib/animator/index.js';
|
||||
const blocks = document.querySelectorAll('.block');
|
||||
const animator = new Animator({duration: 1000, iterations: 1.5});
|
||||
(async function () {
|
||||
let i = 0;
|
||||
while(true) { // eslint-disable-next-line no-await-in-loop
|
||||
await animator.animate(blocks[i++ % 4], ({target, timing}) => {
|
||||
target.style.transform = `rotate(${timing.p * 360}deg)`;
|
||||
});
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
<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', () => {
|
||||
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
|
||||
const left = start * (1 - p) + end * p;
|
||||
el.style.left = `${left}px`;
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
实现技巧也很简单,我们仍然可以使用线性插值,只不过要对插值参数p做一个函数映射。比如说,如果要让方块做初速度为0的匀加速运动,我们可以将p映射为p^2。
|
||||
|
||||
```
|
||||
p = p ** 2;
|
||||
const left = start * (1 - p) + end * p;
|
||||
|
||||
```
|
||||
|
||||
再比如说,如果要让它做末速度为0的匀减速运动,我们可以将p映射为p * (2 - p)。
|
||||
|
||||
```
|
||||
p = p * (2 - p);
|
||||
const left = start * (1 - p) + end * p;
|
||||
|
||||
```
|
||||
|
||||
那为什么匀加速、匀减速的时候,p要这样映射呢?要理解这一点,我们就得先来回忆一下,匀加速和匀减速运动的物理计算公式。
|
||||
|
||||
假设,某个物体在做初速度为0的匀加速运动,运动的总时间为T,总位移为S。那么,它在t时刻的位移和加速度的计算公式如下:
|
||||
|
||||
<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 => p} = {}) {
|
||||
this.startTime = Date.now();
|
||||
this.duration = duration;
|
||||
this.iterations = iterations;
|
||||
this.easing = easing;
|
||||
}
|
||||
|
||||
get time() {
|
||||
return Date.now() - this.startTime;
|
||||
}
|
||||
|
||||
get p() {
|
||||
const progress = Math.min(this.time / this.duration, this.iterations);
|
||||
return this.isFinished ? 1 : this.easing(progress % 1);
|
||||
}
|
||||
|
||||
get isFinished() {
|
||||
return this.time / this.duration >= this.iterations;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那带入到具体的例子中,我们只要多给animator传一个easing参数,就可以让一开始匀速运动的小方块变成匀加速运动了。下面就是我们使用这个缓动函数的具体代码:
|
||||
|
||||
```
|
||||
import {Animator} from '../common/lib/animator/index.js';
|
||||
const block = document.querySelector('.block');
|
||||
const animator = new Animator({duration: 3000, easing: p => p ** 2});
|
||||
document.addEventListener('click', () => {
|
||||
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
|
||||
const left = start * (1 - p) + end * p;
|
||||
el.style.left = `${left}px`;
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
## 贝塞尔曲线缓动
|
||||
|
||||
现在,我们已经缓动函数的应用了。缓动函数有很多种,其中比较常用的是贝塞尔曲线缓动(Bezier-easing),准确地说,是三次贝塞尔曲线缓动函数。接下来,我们就来一起来实现一个简单的贝塞尔曲线缓动。
|
||||
|
||||
我们先来复习一下三次贝塞尔曲线的参数方程:
|
||||
|
||||
<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)(Newton’s 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', () => {
|
||||
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
|
||||
const left = start * (1 - p) + end * p;
|
||||
el.style.left = `${left}px`;
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
这样,我们能得到如下的效果:
|
||||
|
||||
<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/)
|
||||
560
极客时间专栏/跟月影学可视化/视觉高级篇/19 | 如何用着色器实现像素动画?.md
Normal file
560
极客时间专栏/跟月影学可视化/视觉高级篇/19 | 如何用着色器实现像素动画?.md
Normal 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 < 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(() => {
|
||||
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}) => {
|
||||
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}) => {
|
||||
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 < 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)
|
||||
534
极客时间专栏/跟月影学可视化/视觉高级篇/20 | 如何用WebGL绘制3D物体?.md
Normal file
534
极客时间专栏/跟月影学可视化/视觉高级篇/20 | 如何用WebGL绘制3D物体?.md
Normal 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) => {
|
||||
positions.push(vertices[i]);
|
||||
color.push(colors[colorIdx % colorLen]);
|
||||
});
|
||||
cells.push(
|
||||
[0, 1, 2].map(i => i + cellsIdx),
|
||||
[0, 2, 3].map(i => i + cellsIdx),
|
||||
);
|
||||
colorIdx++;
|
||||
cellsIdx += 4;
|
||||
}
|
||||
|
||||
quad(1, 0, 3, 2);
|
||||
quad(4, 5, 6, 7);
|
||||
quad(2, 3, 7, 6);
|
||||
quad(5, 4, 0, 1);
|
||||
quad(3, 0, 4, 7);
|
||||
quad(6, 5, 1, 2);
|
||||
|
||||
return {positions, color, cells};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,我们就可以构建出立方体的顶点信息,我在下面给出了12个立方体的顶点。
|
||||
|
||||
```
|
||||
const geometry = cube(1.0, [
|
||||
[1, 0, 0, 1],
|
||||
[0, 0.5, 0, 1],
|
||||
[1, 0, 1, 1],
|
||||
]);
|
||||
|
||||
```
|
||||
|
||||
通过上面的代码,我们就能创建出一个棱长为1的立方体,并且六个面的颜色分别是“红、绿、蓝、红、绿、蓝”。
|
||||
|
||||
这里我还想补充一点内容,绘制3D图形与绘制2D图形有一点不一样,那就是我们必须要开启**深度检测和启用深度缓冲区**。在WebGL中,我们可以通过`gl.enable(gl.DEPTH_TEST)`,来开启深度检测。
|
||||
|
||||
而且,我们在清空画布的时候,也要用`gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);`,来同时清空颜色缓冲区和深度缓冲区。启动和清空深度检测和深度缓冲区这两个步骤,是这个过程中非常重要的一环,但是我们几乎不会用原生的方式来写代码,所以我们了解到这个程度就可以了。
|
||||
|
||||
事实上,对于上面这些步骤,为了方便使用,我们还是可以直接使用gl-renderer库。它封装了深度检测,在使用它的时候,我们只要在创建renderer的时候设置一个参数depth: true即可。
|
||||
|
||||
现在,我们把这个三维立方体用gl-renderer渲染出来,渲染代码如下:
|
||||
|
||||
```
|
||||
const canvas = document.querySelector('canvas');
|
||||
const renderer = new GlRenderer(canvas, {
|
||||
depth: true,
|
||||
});
|
||||
|
||||
const program = renderer.compileSync(fragment, vertex);
|
||||
renderer.useProgram(program);
|
||||
|
||||
renderer.setMeshData([{
|
||||
positions: geometry.positions,
|
||||
attributes: {
|
||||
color: geometry.color,
|
||||
},
|
||||
cells: geometry.cells,
|
||||
}]);
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
<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 <= segments; i++) {
|
||||
const theta = Math.PI * 2 * i / segments;
|
||||
const p = [radius * Math.cos(theta), radius * Math.sin(theta)];
|
||||
cap.push(p);
|
||||
}
|
||||
|
||||
positions.push(...cap.map(([x, y]) => [x, y, -h]));
|
||||
for(let i = 1; i < cap.length - 1; i++) {
|
||||
cells.push([0, i, i + 1]);
|
||||
}
|
||||
cells.push([0, cap.length - 1, 1]);
|
||||
|
||||
let offset = positions.length;
|
||||
positions.push(...cap.map(([x, y]) => [x, y, h]));
|
||||
for(let i = 1; i < cap.length - 1; i++) {
|
||||
cells.push([offset, offset + i, offset + i + 1]);
|
||||
}
|
||||
cells.push([offset, offset + cap.length - 1, offset + 1]);
|
||||
|
||||
color.push(...positions.map(() => colorCap));
|
||||
|
||||
// 侧面
|
||||
offset = positions.length;
|
||||
for(let i = 1; i < cap.length; i++) {
|
||||
const a = [...cap[i], h];
|
||||
const b = [...cap[i], -h];
|
||||
const nextIdx = i < cap.length - 1 ? i + 1 : 1;
|
||||
const c = [...cap[nextIdx], -h];
|
||||
const d = [...cap[nextIdx], h];
|
||||
|
||||
positions.push(a, b, c, d);
|
||||
color.push(colorSide, colorSide, colorSide, colorSide);
|
||||
cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
return {positions, cells, color};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样呢,我们就可以绘制出圆柱体了,把前面例子代码里的cube改为cylinder,效果如下图所示:
|
||||
|
||||
<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和b,n是它们的法向量,那我们可以得到公式: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 < cap.length; i++) {
|
||||
const a = [...cap[i], h];
|
||||
const b = [...cap[i], -h];
|
||||
const nextIdx = i < cap.length - 1 ? i + 1 : 1;
|
||||
const c = [...cap[nextIdx], -h];
|
||||
const d = [...cap[nextIdx], h];
|
||||
|
||||
positions.push(a, b, c, d);
|
||||
|
||||
const norm = [];
|
||||
cross(norm, subtract(tmp1, b, a), subtract(tmp2, c, a));
|
||||
normalize(norm, norm);
|
||||
normal.push(norm, norm, norm, norm); // abcd四个点共面,它们的法向量相同
|
||||
color.push(colorSide, colorSide, colorSide, colorSide);
|
||||
cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
求出法向量,我们可以使用法向量来实现丰富的效果,比如点光源。下面,我们就在shader中实现点光源效果。
|
||||
|
||||
### 2. 法向量矩阵
|
||||
|
||||
因为我们在shader中,会使用模型矩阵对顶点进行变换,所以在片元着色器中,我们拿到的是变换后的顶点坐标,这时候,如果我们要应用法向量,需要对法向量也进行变换,我们可以通过一个矩阵来实现,这个矩阵叫做法向量矩阵(NormalMatrix)。它是模型矩阵的逆转置矩阵,不过它非常特殊,是一个3X3的矩阵(mat3),而像模型矩阵、投影矩阵等等矩阵都是4X4的。
|
||||
|
||||
得到了法向量和法向量矩阵,我们可以使用法向量和法向量矩阵来实现点光源光照效果。首先,我们要实现如下顶点着色器:
|
||||
|
||||
```
|
||||
attribute vec3 a_vertexPosition;
|
||||
attribute vec4 color;
|
||||
attribute vec3 normal;
|
||||
|
||||
varying vec4 vColor;
|
||||
varying float vCos;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform mat4 modelMatrix;
|
||||
uniform mat3 normalMatrix;
|
||||
|
||||
const vec3 lightPosition = vec3(1, 0, 0);
|
||||
|
||||
void main() {
|
||||
gl_PointSize = 1.0;
|
||||
vColor = color;
|
||||
vec4 pos = modelMatrix * vec4(a_vertexPosition, 1.0);
|
||||
vec3 invLight = lightPosition - pos.xyz;
|
||||
vec3 norm = normalize(normalMatrix * normal);
|
||||
vCos = max(dot(normalize(invLight), norm), 0.0);
|
||||
gl_Position = projectionMatrix * pos;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面顶点着色器的代码中,我们计算的是位于(1,0,0)坐标处的点光源与几何体法线的夹角余弦。那根据物体漫反射模型,光照强度等于光线与法向量夹角的余弦。
|
||||
|
||||
<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)
|
||||
362
极客时间专栏/跟月影学可视化/视觉高级篇/21 | 如何添加相机,用透视原理对物体进行投影?.md
Normal file
362
极客时间专栏/跟月影学可视化/视觉高级篇/21 | 如何添加相机,用透视原理对物体进行投影?.md
Normal 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)
|
||||
308
极客时间专栏/跟月影学可视化/视觉高级篇/22 | 如何用仿射变换来移动和旋转3D物体?.md
Normal file
308
极客时间专栏/跟月影学可视化/视觉高级篇/22 | 如何用仿射变换来移动和旋转3D物体?.md
Normal 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} & 0 & 0 \\\<br>
|
||||
0 & s_{y} & 0 \\\<br>
|
||||
0 & 0 & s_{z}<br>
|
||||
\end{array}\right]<br>
|
||||
$$
|
||||
|
||||
而且,我们也可以使用齐次矩阵来表示三维仿射变换,通过引入一个新的维度,就可以把仿射变换转换为齐次矩阵的线性变换了。
|
||||
|
||||
$$<br>
|
||||
M’=\left[\begin{array}{ccc}<br>
|
||||
M & 0 \\\<br>
|
||||
0 & 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 & 0 & \sin \alpha \\\ 0 & 1 & 0 \\\ -\sin \alpha & 0 & \cos \alpha\end{array}\right]$$
|
||||
|
||||
$$绕x轴旋转:R_{x}=\left[\begin{array}{ccc}1 & 0 & 0 \\\ 0 & \cos \beta & -\sin \beta \\\ 0 & \sin \beta & \cos \beta\end{array}\right]$$
|
||||
|
||||
$$绕z轴旋转:R_{z}=\left[\begin{array}{ccc}\cos \gamma & -\sin \gamma & 0 \\\ \sin \gamma & \cos \gamma & 0 \\\ 0 & 0 & 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&fromid=823115&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$;另一种叫做**Tait–Bryan 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$ 顺规,属于**Tait–Bryan 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是一份几何模型文件,内容类似于下面这样:
|
||||
|
||||
```
|
||||
{
|
||||
"position": [0.752, 1.061, 0.0, 0.767...],
|
||||
"normal": [0.975, 0.224, 0.0, 0.975...],
|
||||
"uv": [0.745, 0.782, 0.705, 0.769...]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中position、normal、uv是顶点数据,我们比较熟悉,分别是顶点坐标、法向量和纹理坐标。这样的数据一般是由设计工具直接生成的,不需要我们来计算。
|
||||
|
||||
接下来,我们加载飞机的纹理图片,同样要先封装一个加载图片纹理的函数。在函数里,我们用img元素加载图片,然后将图片赋给对应的纹理对象。函数代码如下:
|
||||
|
||||
```
|
||||
function loadTexture(src) {
|
||||
const texture = new Texture(gl);
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
texture.image = img;
|
||||
resolve(texture);
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接着,我们就可以加载飞机的纹理图片了。具体操作如下:
|
||||
|
||||
```
|
||||
const texture = await loadTexture('../assets/airplane.jpg');
|
||||
|
||||
```
|
||||
|
||||
然后,我们在片元着色器中,直接读取纹理图片中的颜色信息:
|
||||
|
||||
```
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tMap;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = texture2D(tMap, vUv);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,我们就能将元素渲染出来了。渲染指令如下:
|
||||
|
||||
```
|
||||
const program = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
tMap: {value: texture},
|
||||
},
|
||||
});
|
||||
const mesh = new Mesh(gl, {geometry, program});
|
||||
mesh.setParent(scene);
|
||||
renderer.render({scene, camera});
|
||||
|
||||
```
|
||||
|
||||
最终,我们就能得到可以随意调整欧拉角的飞机模型了,效果如下图所示:
|
||||
|
||||
<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} & 2 x y-2 z w & 2 x z+2 y w \\\<br>
|
||||
2 x y+2 z w & 1-2 x^{2}-2 z^{2} & 2 y z-2 x w \\\<br>
|
||||
2 x z-2 y w & 2 y z+2 x w & 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)
|
||||
377
极客时间专栏/跟月影学可视化/视觉高级篇/23 | 如何模拟光照让3D场景更逼真?(上).md
Normal file
377
极客时间专栏/跟月影学可视化/视觉高级篇/23 | 如何模拟光照让3D场景更逼真?(上).md
Normal 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)
|
||||
419
极客时间专栏/跟月影学可视化/视觉高级篇/24 | 如何模拟光照让3D场景更逼真?(下).md
Normal file
419
极客时间专栏/跟月影学可视化/视觉高级篇/24 | 如何模拟光照让3D场景更逼真?(下).md
Normal 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 && !direction) throw new TypeError('invalid light');
|
||||
light.color = color || [1, 1, 1];
|
||||
if(!position) this.directionalLights.add(light);
|
||||
else {
|
||||
light.decay = decay || [0, 0, 1];
|
||||
if(!angle) {
|
||||
this.pointLights.add(light);
|
||||
} else {
|
||||
this.spotLights.add(light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeLight(light) {
|
||||
if(this.directionalLights.has(light)) this.directionalLights.delete(light);
|
||||
else if(this.pointLights.has(light)) this.pointLights.delete(light);
|
||||
else if(this.spotLights.has(light)) this.spotLights.delete(light);
|
||||
}
|
||||
|
||||
get uniforms() {
|
||||
const MAX_LIGHT_COUNT = 16; // 最多每种光源设置16个
|
||||
this._lightData = this._lightData || {};
|
||||
const lightData = this._lightData;
|
||||
|
||||
lightData.directionalLightDirection = lightData.directionalLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
lightData.directionalLightColor = lightData.directionalLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
|
||||
lightData.pointLightPosition = lightData.pointLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
lightData.pointLightColor = lightData.pointLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
lightData.pointLightDecay = lightData.pointLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
|
||||
lightData.spotLightDirection = lightData.spotLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
lightData.spotLightPosition = lightData.spotLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
lightData.spotLightColor = lightData.spotLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
lightData.spotLightDecay = lightData.spotLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
|
||||
lightData.spotLightAngle = lightData.spotLightAngle || {value: new Float32Array(MAX_LIGHT_COUNT)};
|
||||
|
||||
[...this.directionalLights].forEach((light, idx) => {
|
||||
lightData.directionalLightDirection.value.set(light.direction, idx * 3);
|
||||
lightData.directionalLightColor.value.set(light.color, idx * 3);
|
||||
});
|
||||
|
||||
[...this.pointLights].forEach((light, idx) => {
|
||||
lightData.pointLightPosition.value.set(light.position, idx * 3);
|
||||
lightData.pointLightColor.value.set(light.color, idx * 3);
|
||||
lightData.pointLightDecay.value.set(light.decay, idx * 3);
|
||||
});
|
||||
|
||||
[...this.spotLights].forEach((light, idx) => {
|
||||
lightData.spotLightPosition.value.set(light.position, idx * 3);
|
||||
lightData.spotLightColor.value.set(light.color, idx * 3);
|
||||
lightData.spotLightDecay.value.set(light.decay, idx * 3);
|
||||
lightData.spotLightDirection.value.set(light.direction, idx * 3);
|
||||
lightData.spotLightAngle.value[idx] = light.angle;
|
||||
});
|
||||
|
||||
return {
|
||||
ambientLight: {value: this.ambientLight},
|
||||
...lightData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了这个类之后,我们就可以创建并添加各种光源了。我在下面的代码中,添加了一个平行光和两个点光源,你可以看看。
|
||||
|
||||
```
|
||||
const phong = new Phong();
|
||||
// 添加一个平行光
|
||||
phong.addLight({
|
||||
direction: [-1, 0, 0],
|
||||
});
|
||||
// 添加两个点光源
|
||||
phong.addLight({
|
||||
position: [-3, 3, 0],
|
||||
color: [1, 0, 0],
|
||||
});
|
||||
|
||||
phong.addLight({
|
||||
position: [3, 3, 0],
|
||||
color: [0, 0, 1],
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
### 2. 定义几何体材质
|
||||
|
||||
定义完光源之后,我们还需要定义几何体的**材质**(material),因为几何体材质决定了光反射的性质。
|
||||
|
||||
在前面的课程里,我们已经了解了一种与几何体材质有关的变量,即物体的反射率(MaterialReflection)。那在前面计算镜面反射的公式,float specular = 2.0 * pow(eyeCos, 50.0);中也有两个常量2.0和50.0,把它们也提取出来,我们就能得到两个新的变量。其中,2.0对应specularFactor,表示镜面反射强度,50.0指的是shininess,表示镜面反射的光洁度。
|
||||
|
||||
这样,我们就有了3个与材质有关的变量,分别是matrialReflection (材质反射率)、specularFactor (镜面反射强度)、以及shininess (镜面反射光洁度)。
|
||||
|
||||
然后,我们可以创建一个Matrial类,来定义物体的材质。与光源类相比,这个类非常简单,只是设置这三个参数,并通过uniforms访问器属性,获得它的uniform数据结构形式。
|
||||
|
||||
```
|
||||
class Material {
|
||||
constructor(reflection, specularFactor = 0, shininess = 50) {
|
||||
this.reflection = reflection;
|
||||
this.specularFactor = specularFactor;
|
||||
this.shininess = shininess;
|
||||
}
|
||||
|
||||
get uniforms() {
|
||||
return {
|
||||
materialReflection: {value: this.reflection},
|
||||
specularFactor: {value: this.specularFactor},
|
||||
shininess: {value: this.shininess},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
那么,我们就可以创建matrial对象了。这里,我一共创建4个matrial对象,分别对应要显示的四个几何体的材质。
|
||||
|
||||
```
|
||||
const matrial1 = new Material(new Color('#0000ff'), 2.0);
|
||||
const matrial2 = new Material(new Color('#ff00ff'), 2.0);
|
||||
const matrial3 = new Material(new Color('#008000'), 2.0);
|
||||
const matrial4 = new Material(new Color('#ff0000'), 2.0);
|
||||
|
||||
```
|
||||
|
||||
有了phong对象和matrial对象,我们就可以给几何体创建WebGL程序了。那我们就使用上面四个WebGL程序,来创建真正的几何体网格,并将它们渲染出来吧。具体代码如下:
|
||||
|
||||
```
|
||||
const program1 = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
...matrial1.uniforms,
|
||||
...phong.uniforms,
|
||||
},
|
||||
});
|
||||
const program2 = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
...matrial2.uniforms,
|
||||
...phong.uniforms,
|
||||
},
|
||||
});
|
||||
const program3 = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
...matrial3.uniforms,
|
||||
...phong.uniforms,
|
||||
},
|
||||
});
|
||||
const program4 = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
...matrial4.uniforms,
|
||||
...phong.uniforms,
|
||||
},
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
### 3. 实现着色器
|
||||
|
||||
接下来,我们重点看一下,支持phong反射模型的片元着色器代码是怎么实现的。这个着色器代码比较复杂,我们一段一段来看。
|
||||
|
||||
首先,我们来看光照相关的uniform变量的声明。这里,我们声明了vec3和float数组,数组的大小为16。这样,对于每一种光源,我们都可以支持16个。
|
||||
|
||||
```
|
||||
#define MAX_LIGHT_COUNT 16
|
||||
uniform mat4 viewMatrix;
|
||||
|
||||
uniform vec3 ambientLight;
|
||||
uniform vec3 directionalLightDirection[MAX_LIGHT_COUNT];
|
||||
uniform vec3 directionalLightColor[MAX_LIGHT_COUNT];
|
||||
uniform vec3 pointLightColor[MAX_LIGHT_COUNT];
|
||||
uniform vec3 pointLightPosition[MAX_LIGHT_COUNT];
|
||||
uniform vec3 pointLightDecay[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightColor[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightDirection[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightPosition[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightDecay[MAX_LIGHT_COUNT];
|
||||
uniform float spotLightAngle[MAX_LIGHT_COUNT];
|
||||
|
||||
uniform vec3 materialReflection;
|
||||
uniform float shininess;
|
||||
uniform float specularFactor;
|
||||
|
||||
```
|
||||
|
||||
接下来,我们实现计算phong反射模型的主题逻辑。事实上,处理平行光、点光源、聚光灯的主体逻辑类似,都是循环处理每个光源,再计算入射光方向,然后计算漫反射以及镜面反射,最终将结果返回。
|
||||
|
||||
```
|
||||
float getSpecular(vec3 dir, vec3 normal, vec3 eye) {
|
||||
vec3 reflectionLight = reflect(-dir, normal);
|
||||
float eyeCos = max(dot(eye, reflectionLight), 0.0);
|
||||
return specularFactor * pow(eyeCos, shininess);
|
||||
}
|
||||
|
||||
vec4 phongReflection(vec3 pos, vec3 normal, vec3 eye) {
|
||||
float specular = 0.0;
|
||||
vec3 diffuse = vec3(0);
|
||||
|
||||
// 处理平行光
|
||||
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
|
||||
vec3 dir = directionalLightDirection[i];
|
||||
if(dir.x == 0.0 && dir.y == 0.0 && dir.z == 0.0) continue;
|
||||
vec4 d = viewMatrix * vec4(dir, 0.0);
|
||||
dir = normalize(-d.xyz);
|
||||
float cos = max(dot(dir, normal), 0.0);
|
||||
diffuse += cos * directionalLightColor[i];
|
||||
specular += getSpecular(dir, normal, eye);
|
||||
}
|
||||
|
||||
// 处理点光源
|
||||
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
|
||||
vec3 decay = pointLightDecay[i];
|
||||
if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
|
||||
vec3 dir = (viewMatrix * vec4(pointLightPosition[i], 1.0)).xyz - pos;
|
||||
float dis = length(dir);
|
||||
dir = normalize(dir);
|
||||
float cos = max(dot(dir, normal), 0.0);
|
||||
float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
|
||||
diffuse += d * cos * pointLightColor[i];
|
||||
specular += getSpecular(dir, normal, eye);
|
||||
}
|
||||
|
||||
// 处理聚光灯
|
||||
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
|
||||
vec3 decay = spotLightDecay[i];
|
||||
if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
|
||||
|
||||
vec3 dir = (viewMatrix * vec4(spotLightPosition[i], 1.0)).xyz - pos;
|
||||
float dis = length(dir);
|
||||
dir = normalize(dir);
|
||||
|
||||
// 聚光灯的朝向
|
||||
vec3 spotDir = (viewMatrix * vec4(spotLightDirection[i], 0.0)).xyz;
|
||||
// 通过余弦值判断夹角范围
|
||||
float ang = cos(spotLightAngle[i]);
|
||||
float r = step(ang, dot(dir, normalize(-spotDir)));
|
||||
|
||||
float cos = max(dot(dir, normal), 0.0);
|
||||
float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
|
||||
diffuse += r * d * cos * spotLightColor[i];
|
||||
specular += r * getSpecular(dir, normal, eye);
|
||||
}
|
||||
|
||||
return vec4(diffuse, specular);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,我们在main函数中,调用phongReflection函数来合成颜色。代码如下:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec3 eyeDirection = normalize(vCameraPos - vPos);
|
||||
vec4 phong = phongReflection(vPos, vNormal, eyeDirection);
|
||||
|
||||
// 合成颜色
|
||||
gl_FragColor.rgb = phong.w + (phong.xyz + ambientLight) * materialReflection;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最终呈现的视觉效果如下图所示:
|
||||
|
||||
<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)
|
||||
409
极客时间专栏/跟月影学可视化/视觉高级篇/25 | 如何用法线贴图模拟真实物体表面.md
Normal file
409
极客时间专栏/跟月影学可视化/视觉高级篇/25 | 如何用法线贴图模拟真实物体表面.md
Normal 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 < 3) throw new Error('Error dimension.');
|
||||
|
||||
const len = position.data.length / size;
|
||||
const tang = new Float32Array(len * 3);
|
||||
const bitang = new Float32Array(len * 3);
|
||||
|
||||
for(let i = 0; i < index.data.length; i += 3) {
|
||||
const i1 = index.data[i];
|
||||
const i2 = index.data[i + 1];
|
||||
const i3 = index.data[i + 2];
|
||||
|
||||
const p1 = [position.data[i1 * size], position.data[i1 * size + 1], position.data[i1 * size + 2]];
|
||||
const p2 = [position.data[i2 * size], position.data[i2 * size + 1], position.data[i2 * size + 2]];
|
||||
const p3 = [position.data[i3 * size], position.data[i3 * size + 1], position.data[i3 * size + 2]];
|
||||
|
||||
const u1 = [uv.data[i1 * 2], uv.data[i1 * 2 + 1]];
|
||||
const u2 = [uv.data[i2 * 2], uv.data[i2 * 2 + 1]];
|
||||
const u3 = [uv.data[i3 * 2], uv.data[i3 * 2 + 1]];
|
||||
|
||||
const {tang: t, bitang: b} = getTBNTriangle(p1, p2, p3, u1, u2, u3);
|
||||
tang.set(t, i1 * 3);
|
||||
tang.set(t, i2 * 3);
|
||||
tang.set(t, i3 * 3);
|
||||
bitang.set(b, i1 * 3);
|
||||
bitang.set(b, i2 * 3);
|
||||
bitang.set(b, i3 * 3);
|
||||
}
|
||||
geometry.addAttribute('tang', {data: tang, size: 3});
|
||||
geometry.addAttribute('bitang', {data: bitang, size: 3});
|
||||
return geometry;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
虽然上面这段代码比较长,但并不复杂。具体的思路就是按照我给出的公式,先进行向量计算,然后将tang和bitang的值添加到geometry对象中去。
|
||||
|
||||
### 构建TBN矩阵来计算法向量
|
||||
|
||||
有了tang和bitang之后,我们就可以构建TBN矩阵来计算法线了。这里的TBN矩阵的作用,就是将法线贴图里面读取的法向量数据,转换为对应的切线空间中实际的法向量。这里的切线空间,实际上对应着我们观察者(相机)位置的坐标系。
|
||||
|
||||
接下来,我们对应顶点着色器和片元着色器来说说,怎么构建TBN矩阵得出法线方向。
|
||||
|
||||
先看顶点着色器,我们增加了tang和bitang这两个属性。注意,这里我们用了webgl2.0的写法,因为WebGL2.0对应OpenGL ES3.0,所以这段代码和我们之前看到的着色器代码略有不同。
|
||||
|
||||
首先它的第一行声明 #version 300 es 表示这段代码是OpenGL ES3.0的,然后我们用in和out对应变量的输入和输出,来取代WebGL2.0的attribute和varying,其他的地方基本和WebGL1.0一样。因为OGL默认支持WebGL2.0,所以在后续例子中你还会看到更多OpenGL ES3.0的着色器写法,不过因为两个版本差别不大,也不会妨碍我们理解代码。
|
||||
|
||||
```
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec3 position;
|
||||
in vec3 normal;
|
||||
in vec2 uv;
|
||||
in vec3 tang;
|
||||
in vec3 bitang;
|
||||
|
||||
uniform mat4 modelMatrix;
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 viewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform mat3 normalMatrix;
|
||||
uniform vec3 cameraPosition;
|
||||
|
||||
out vec3 vNormal;
|
||||
out vec3 vPos;
|
||||
out vec2 vUv;
|
||||
out vec3 vCameraPos;
|
||||
out mat3 vTBN;
|
||||
|
||||
void main() {
|
||||
vec4 pos = modelViewMatrix * vec4(position, 1.0);
|
||||
vPos = pos.xyz;
|
||||
vUv = uv;
|
||||
vCameraPos = (viewMatrix * vec4(cameraPosition, 1.0)).xyz;
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
|
||||
vec3 N = vNormal;
|
||||
vec3 T = normalize(normalMatrix * tang);
|
||||
vec3 B = normalize(normalMatrix * bitang);
|
||||
|
||||
vTBN = mat3(T, B, N);
|
||||
|
||||
gl_Position = projectionMatrix * pos;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
接着来看代码,我们通过normal、tang和bitang建立TBN矩阵。注意,因为normal、tang和bitang都需要换到世界坐标中,所以我们要记得将它们左乘法向量矩阵normalMatrix,然后我们构建TBN矩阵(vTBN=mat(T,B,N)),将它传给片元着色器。
|
||||
|
||||
下面,我们接着来看片元着色器。
|
||||
|
||||
```
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
#define MAX_LIGHT_COUNT 16
|
||||
uniform mat4 viewMatrix;
|
||||
|
||||
uniform vec3 ambientLight;
|
||||
uniform vec3 directionalLightDirection[MAX_LIGHT_COUNT];
|
||||
uniform vec3 directionalLightColor[MAX_LIGHT_COUNT];
|
||||
uniform vec3 pointLightColor[MAX_LIGHT_COUNT];
|
||||
uniform vec3 pointLightPosition[MAX_LIGHT_COUNT];
|
||||
uniform vec3 pointLightDecay[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightColor[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightDirection[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightPosition[MAX_LIGHT_COUNT];
|
||||
uniform vec3 spotLightDecay[MAX_LIGHT_COUNT];
|
||||
uniform float spotLightAngle[MAX_LIGHT_COUNT];
|
||||
|
||||
uniform vec3 materialReflection;
|
||||
uniform float shininess;
|
||||
uniform float specularFactor;
|
||||
|
||||
uniform sampler2D tNormal;
|
||||
|
||||
in vec3 vNormal;
|
||||
in vec3 vPos;
|
||||
in vec2 vUv;
|
||||
in vec3 vCameraPos;
|
||||
in mat3 vTBN;
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
float getSpecular(vec3 dir, vec3 normal, vec3 eye) {
|
||||
vec3 reflectionLight = reflect(-dir, normal);
|
||||
float eyeCos = max(dot(eye, reflectionLight), 0.0);
|
||||
return specularFactor * pow(eyeCos, shininess);
|
||||
}
|
||||
|
||||
vec4 phongReflection(vec3 pos, vec3 normal, vec3 eye) {
|
||||
float specular = 0.0;
|
||||
vec3 diffuse = vec3(0);
|
||||
|
||||
// 处理平行光
|
||||
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
|
||||
vec3 dir = directionalLightDirection[i];
|
||||
if(dir.x == 0.0 && dir.y == 0.0 && dir.z == 0.0) continue;
|
||||
vec4 d = viewMatrix * vec4(dir, 0.0);
|
||||
dir = normalize(-d.xyz);
|
||||
float cos = max(dot(dir, normal), 0.0);
|
||||
diffuse += cos * directionalLightColor[i];
|
||||
specular += getSpecular(dir, normal, eye);
|
||||
}
|
||||
|
||||
// 处理点光源
|
||||
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
|
||||
vec3 decay = pointLightDecay[i];
|
||||
if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
|
||||
vec3 dir = (viewMatrix * vec4(pointLightPosition[i], 1.0)).xyz - pos;
|
||||
float dis = length(dir);
|
||||
dir = normalize(dir);
|
||||
float cos = max(dot(dir, normal), 0.0);
|
||||
float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
|
||||
diffuse += d * cos * pointLightColor[i];
|
||||
specular += getSpecular(dir, normal, eye);
|
||||
}
|
||||
|
||||
// 处理聚光灯
|
||||
for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
|
||||
vec3 decay = spotLightDecay[i];
|
||||
if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
|
||||
|
||||
vec3 dir = (viewMatrix * vec4(spotLightPosition[i], 1.0)).xyz - pos;
|
||||
float dis = length(dir);
|
||||
dir = normalize(dir);
|
||||
|
||||
// 聚光灯的朝向
|
||||
vec3 spotDir = (viewMatrix * vec4(spotLightDirection[i], 0.0)).xyz;
|
||||
// 通过余弦值判断夹角范围
|
||||
float ang = cos(spotLightAngle[i]);
|
||||
float r = step(ang, dot(dir, normalize(-spotDir)));
|
||||
|
||||
float cos = max(dot(dir, normal), 0.0);
|
||||
float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
|
||||
diffuse += r * d * cos * spotLightColor[i];
|
||||
specular += r * getSpecular(dir, normal, eye);
|
||||
}
|
||||
|
||||
return vec4(diffuse, specular);
|
||||
}
|
||||
|
||||
vec3 getNormal() {
|
||||
vec3 n = texture(tNormal, vUv).rgb * 2.0 - 1.0;
|
||||
return normalize(vTBN * n);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 eyeDirection = normalize(vCameraPos - vPos);
|
||||
vec3 normal = getNormal();
|
||||
vec4 phong = phongReflection(vPos, normal, eyeDirection);
|
||||
|
||||
// 合成颜色
|
||||
FragColor.rgb = phong.w + (phong.xyz + ambientLight) * materialReflection;
|
||||
FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
片元着色器代码虽然也很长,但也并不复杂。因为其中的Phong反射模型,我们已经比较熟悉了。剩下的部分,我们重点理解,怎么从法线纹理中提取数据和TBN矩阵,来计算对应的法线就行了。具体的计算方法就是把法线纹理贴图中提取的数据转换到[-1,1]区间,然后左乘TBN矩阵并归一化。
|
||||
|
||||
然后,我们将经过处理之后的法向量传给phongReflection计算光照,就得到了法线贴图后的结果,效果如下图:
|
||||
|
||||
<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)
|
||||
337
极客时间专栏/跟月影学可视化/视觉高级篇/26 | 如何绘制带宽度的曲线?.md
Normal file
337
极客时间专栏/跟月影学可视化/视觉高级篇/26 | 如何绘制带宽度的曲线?.md
Normal 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 < 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 < 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, x)和(y, -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="转角的挤压方向示意图">
|
||||
|
||||
如上图,我们假设有折线abc,b是转角。我们延长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 < points.length - 1; i++) {
|
||||
const v1 = (new Vec2()).sub(points[i], points[i - 1]).normalize();
|
||||
const v2 = (new Vec2()).sub(points[i], points[i + 1]).normalize();
|
||||
const v = (new Vec2()).add(v1, v2).normalize(); // 得到挤压方向
|
||||
const norm = new Vec2(-v1.y, v1.x); // 法线方向
|
||||
const cos = norm.dot(v);
|
||||
const len = halfThick / cos;
|
||||
if(i === 1) { // 起始点
|
||||
const v0 = new Vec2(...norm).scale(halfThick);
|
||||
outerSide.push((new Vec2()).add(points[0], v0));
|
||||
innerSide.push((new Vec2()).sub(points[0], v0));
|
||||
}
|
||||
v.scale(len);
|
||||
outerSide.push((new Vec2()).add(points[i], v));
|
||||
innerSide.push((new Vec2()).sub(points[i], v));
|
||||
if(i === points.length - 2) { // 结束点
|
||||
const norm2 = new Vec2(v2.y, -v2.x);
|
||||
const v0 = new Vec2(...norm2).scale(halfThick);
|
||||
outerSide.push((new Vec2()).add(points[points.length - 1], v0));
|
||||
innerSide.push((new Vec2()).sub(points[points.length - 1], v0));
|
||||
}
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,v1、v2是线段的延长线,v是挤压方向,我们计算法线方向与挤压方向的余弦值,就能算出挤压长度了。你还要注意,我们要把起始点和结束点这两个端点的挤压也给添加进去,也就是两个if条件中的处理逻辑。
|
||||
|
||||
这样一来,我们就把挤压之后的折线顶点坐标给计算出来了。向内和向外挤压的点现在分别保存在innerSide和outerSide数组中。
|
||||
|
||||
接下来,我们就要构建对应的Geometry对象,所以我们继续添加extrudePolyline函数的后半部分。
|
||||
|
||||
```
|
||||
function extrudePolyline(gl, points, {thickness = 10} = {})
|
||||
...
|
||||
const count = innerSide.length * 4 - 4;
|
||||
const position = new Float32Array(count * 2);
|
||||
const index = new Uint16Array(6 * count / 4);
|
||||
|
||||
// 创建 geometry 对象
|
||||
for(let i = 0; i < innerSide.length - 1; i++) {
|
||||
const a = innerSide[i],
|
||||
b = outerSide[i],
|
||||
c = innerSide[i + 1],
|
||||
d = outerSide[i + 1];
|
||||
|
||||
const offset = i * 4;
|
||||
index.set([offset, offset + 1, offset + 2, offset + 2, offset + 1, offset + 3], i * 6);
|
||||
position.set([...a, ...b, ...c, ...d], i * 8);
|
||||
}
|
||||
|
||||
return new Geometry(gl, {
|
||||
position: {size: 2, data: position},
|
||||
index: {data: index},
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这一步骤就非常简单了,我们根据innerSide和outerSide中的顶点来构建三角网格化的几何体顶点数据,最终返回Geometry对象。
|
||||
|
||||
<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为miter,lineCap为butt的曲线。不过,想要实现lineJoins为bevel或round,lineCap为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)
|
||||
353
极客时间专栏/跟月影学可视化/视觉高级篇/27 | 案例:如何实现简单的3D可视化图表?.md
Normal file
353
极客时间专栏/跟月影学可视化/视觉高级篇/27 | 案例:如何实现简单的3D可视化图表?.md
Normal 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
|
||||
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"date": "2020-06-12",
|
||||
"count": 1,
|
||||
"color":"#c6e48b",
|
||||
},
|
||||
...
|
||||
],
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这份JSON文件中,我们可以取出每一天的提交次数count,以及一个颜色数据color。每天提交的次数越多,颜色就越深。有了这份数据内容,我们就可以着手实现具体的展现了。不过,因为数据很多,所以这次我们只想展现最近一年的数据。我们可以写一个函数,根据传入的时间对数据进行过滤。
|
||||
|
||||
这个函数的代码如下:
|
||||
|
||||
```
|
||||
let cache = null;
|
||||
async function getData(toDate = new Date()) {
|
||||
if(!cache) {
|
||||
const data = await (await fetch('../assets/github_contributions_akira-cn.json')).json();
|
||||
cache = data.contributions.map((o) => {
|
||||
o.date = new Date(o.date.replace(/-/g, '/'));
|
||||
return o;
|
||||
});
|
||||
}
|
||||
// 要拿到 toData 日期之前大约一年的数据(52周)
|
||||
let start = 0,
|
||||
end = cache.length;
|
||||
// 用二分法查找
|
||||
while(start < end - 1) {
|
||||
const mid = Math.floor(0.5 * (start + end));
|
||||
const {date} = cache[mid];
|
||||
if(date <= toDate) end = mid;
|
||||
else start = mid;
|
||||
}
|
||||
// 获得对应的一年左右的数据
|
||||
let day;
|
||||
if(end >= cache.length) {
|
||||
day = toDate.getDay();
|
||||
} else {
|
||||
const lastItem = cache[end];
|
||||
day = lastItem.date.getDay();
|
||||
}
|
||||
// 根据当前星期几,再往前拿52周的数据
|
||||
const len = 7 * 52 + day + 1;
|
||||
const ret = cache.slice(end, end + len);
|
||||
if(ret.length < len) {
|
||||
// 日期超过了数据范围,补齐数据
|
||||
const pad = new Array(len - ret.length).fill({count: 0, color: '#ebedf0'});
|
||||
ret.push(...pad);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个函数的逻辑是,先从JSON文件中读取数据并缓存起来,然后传入对应的日期对象,获取该日期之前大约一年的数据(准确来说是该日期的前52周数据,再加上该日期当前周直到该日期为止的数据,公式为 7*52 + day + 1)。
|
||||
|
||||
这样,我们就准备好了要用来展现的数据。
|
||||
|
||||
## 第二步:用SpriteJS渲染数据、完成绘图
|
||||
|
||||
有了数据之后,接下来我们就要把数据渲染出来,完成绘图。这里,我们要用到一个新的JavaScript库SpriteJS来绘制。
|
||||
|
||||
既然如此,我们先来熟悉一下SpriteJS库。
|
||||
|
||||
[SpriteJS](https://spritejs.org/#/)是基于WebGL的图形库,也是我设计和维护的开源可视化图形渲染引擎项目。它是一个支持树状元素结构的渲染库。也就是说,它和我们前端操作DOM类似,通过将元素一一添加到渲染树上,就可以完成最终的渲染。所以在后续的课程中,我们也会更多地用到它。
|
||||
|
||||
我们要用到的是SpriteJS的3D部分,它是基于我们熟悉的OGL库实现的。那我们为什么不直接用OGL库呢?这是因为SpriteJS在OGL的基础上,对几何体元素进行了类似DOM元素的封装。这样我们创建几何体元素就可以像操作DOM一样方便了,直接用d3库的selection子模块来操作就可以了。
|
||||
|
||||
### 1. 创建Scene对象
|
||||
|
||||
像DOM有documentElement作为根元素一样,SpriteJS也有根元素。SpriteJS的根元素是一个Scene对象,对应一个DOM元素作为容器。更形象点来说,我们可以把Scene理解为一个“场景”。那SpriteJS中渲染图形,都要在这个“场景”中进行。
|
||||
|
||||
接下来,我们就创建一个Scene对象,代码如下:
|
||||
|
||||
```
|
||||
const container = document.getElementById('stage');
|
||||
|
||||
const scene = new Scene({
|
||||
container,
|
||||
displayRatio: 2,
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
创建Scene对象,我们需要两个参数。一个参数是container,它是一个HTML元素,在这里是一个id为stage的元素,这个元素会作为SpriteJS的容器元素,之后SpriteJS会在这个元素上创建Canvas子元素。
|
||||
|
||||
第二个参数是displayRatio,这个参数是用来设置显示分辨率的。你应该还记得,在讲Canvas绘图的时候,我们提到过,为了让绘制出来的图形能够适配不同的显示设备,我们要把Canvas的像素宽高和CSS样式宽高设置成不同的值。所以这里,我们把displayRatio设为2,就可以让像素宽高是CSS样式宽高的2倍,对于一些像素密度为2的设备(如iPhone的屏幕),这么设置才不会让画布上绘制的图片、文字变得模糊。
|
||||
|
||||
### 2. 创建Layer对象
|
||||
|
||||
有了scene对象,我们再创建一个或多个Layer对象,也可以理解为是一个或者多个“图层”。在SpriteJS中,一个Layer对象就对应于一个Canvas画布。
|
||||
|
||||
```
|
||||
const layer = scene.layer3d('fglayer', {
|
||||
camera: {
|
||||
fov: 35,
|
||||
},
|
||||
});
|
||||
layer.camera.attributes.pos = [2, 6, 9];
|
||||
layer.camera.lookAt([0, 0, 0]);
|
||||
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,我们通过调用scene.layer3d方法,就可以在scene对象上创建了一个3D(WebGL)上下文的Canvas画布。而且这里,我们把相机的视角设置为35度,坐标位置为(2, 6, 9),相机朝向坐标原点。
|
||||
|
||||
### 3. 将数据转换成柱状元素
|
||||
|
||||
接着,我们就要把数据转换成画布上的长方体元素。我们可以借助[d3-selection](https://github.com/d3/d3-selection),d3是一个数据驱动文档的模型,d3-selection能够通过数据操作文档树,添加元素节点。当然,在使用d3-selection添加元素前,我们要先创建用来3D展示的WebGL程序。
|
||||
|
||||
因为SpriteJS提供了一些预置的着色器,比如shaders.GEOMETRY着色器,就是默认支持phong反射模型的一组着色器,我们直接调用它就可以了。
|
||||
|
||||
```
|
||||
const program = layer.createProgram({
|
||||
vertex: shaders.GEOMETRY.vertex,
|
||||
fragment: shaders.GEOMETRY.fragment,
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
创建好WebGL程序之后,我们就可以获取数据,用数据来操作文档树了。
|
||||
|
||||
```
|
||||
const dataset = await getData();
|
||||
const max = d3.max(dataset, (a) => {
|
||||
return a.count;
|
||||
});
|
||||
|
||||
/* globals d3 */
|
||||
const selection = d3.select(layer);
|
||||
const chart = selection.selectAll('cube')
|
||||
.data(dataset)
|
||||
.enter()
|
||||
.append(() => {
|
||||
return new Cube(program);
|
||||
})
|
||||
.attr('width', 0.14)
|
||||
.attr('depth', 0.14)
|
||||
.attr('height', 1)
|
||||
.attr('scaleY', (d) => {
|
||||
return d.count / max;
|
||||
})
|
||||
.attr('pos', (d, i) => {
|
||||
const x0 = -3.8 + 0.0717 + 0.0015;
|
||||
const z0 = -0.5 + 0.05 + 0.0015;
|
||||
const x = x0 + 0.143 * Math.floor(i / 7);
|
||||
const z = z0 + 0.143 * (i % 7);
|
||||
return [x, 0.5 * d.count /max, z];
|
||||
})
|
||||
.attr('colors', (d, i) => {
|
||||
return d.color;
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,我们先通过d3.select(layer)对象获得一个selection对象,再通过getData()获得数据,接着通过selection.selectAll(‘cube’).data(dataset).enter().append(…)遍历数据,创建元素节点。
|
||||
|
||||
这里,我们创建了Cube元素,就是长方体在SpriteJS中对应的对象,然后让dataset的每一条记录对应一个Cube元素,接着我们还要设置每个Cube元素的样式,让数据进入cube以后,能体现出不同的形状。
|
||||
|
||||
具体来说,我们要设置长方体Cube的长(width)、宽(depth)、高(height)属性,以及y轴的缩放(scaleY),还有Cube的位置(pos)坐标和长方体的颜色(colors)。其中与数据有关的参数是scaleY、pos和colors,我就来详细说说它们。
|
||||
|
||||
对于scaleY,我们把它设置为d.count与max的比值。这里的max是指一年的提交记录中,提交代码最多那天的数值。这样,我们就可以保证scaleY的值在0~1之间,既不会太小、也不会太大。这种用相对数值来做可视化展现的做法,是可视化处理数据的一种常用基础技巧,在数据篇我们还会深入去讲。
|
||||
|
||||
而pos是根据数据的索引设置x和z来决定的。由于Cube的坐标基于中心点对齐的,现在我们想让它们变成底部对齐,所以需要把y设置为d.count/max的一半。
|
||||
|
||||
最后,我们再根据数据中的color值设置Cube的颜色。这样,我们通过数据将元素添加之后,画布上渲染出来的结果就是一个3D柱状图了,效果如下:
|
||||
|
||||
<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.1,y的值本来应该是-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(() => {
|
||||
return new Cube(program);
|
||||
})
|
||||
.attr('width', 0.14)
|
||||
.attr('depth', 0.14)
|
||||
.attr('height', 1)
|
||||
.attr('scaleY', 0.001)
|
||||
.attr('pos', (d, i) => {
|
||||
const x0 = -3.8 + 0.0717 + 0.0015;
|
||||
const z0 = -0.5 + 0.05 + 0.0015;
|
||||
const x = x0 + 0.143 * Math.floor(i / 7);
|
||||
const z = z0 + 0.143 * (i % 7);
|
||||
return [x, 0, z];
|
||||
})
|
||||
.attr('colors', (d, i) => {
|
||||
return d.color;
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,我们先把scaleY直接设为0.001,然后我们用d3.scaleLinear来创建一个线性的缩放过程,最后,我们通过chart.trainsition来实现这个线性动画。
|
||||
|
||||
```
|
||||
const linear = d3.scaleLinear()
|
||||
.domain([0, max])
|
||||
.range([0, 1.0]);
|
||||
|
||||
chart.transition()
|
||||
.duration(2000)
|
||||
.attr('scaleY', (d, i) => {
|
||||
return linear(d.count);
|
||||
})
|
||||
.attr('y', (d, i) => {
|
||||
return 0.5 * linear(d.count);
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
到这里呢,我们就实现了我们想要实现的所有效果了。
|
||||
|
||||
## 要点总结
|
||||
|
||||
这节课,我们一起实现了3D动态的GitHub贡献图表,整个实现过程可以总结为两步。
|
||||
|
||||
第一步是处理数据,我们可以通过API获取JSON数据,然后得到我们想要的数据格式。第二步是渲染数据,今天我们是使用SpriteJS来渲染的,它的API类似于DOM,对d3非常友好。所以我们可以直接使用d3-selection,以数据驱动文档的方式就可以构建几何体元素。
|
||||
|
||||
并且,为了更好地展现数据之间的变换关系,我们根据数据创建了Cube元素,并将它们渲染了出来。而且,我们还给实现的柱状元素设置了光照、实现了过渡动画,算是实现了一个比较完整的可视化效果。
|
||||
|
||||
此外,我们还要注意,在实现过渡动画的过程中,很容易出现z-fighting问题,也就是我们实现的元素由于次序问题,在渲染的时候出现闪烁。这个问题在可视化中非常常见,不过,我们通过设置渲染次序或者避免坐标相同就可以避免。
|
||||
|
||||
到这里,我们视觉进阶篇的内容就全部讲完了。这一篇,我从实现简单的动画,讲到了3D物体的绘制、旋转、移动,以及给它们添加光照效果、法线贴图,让它们能更贴近真实的物体。
|
||||
|
||||
说实话,这一篇的内容单看真的不简单。但你认真看了会发现,所有的知识都是环环相扣的,只要有了前几篇的基础,我们再来学肯定可以学会。为了帮助你梳理这一篇的内容,我总结了一张知识脑图放在了下面,你可以看看。
|
||||
|
||||
<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)
|
||||
Reference in New Issue
Block a user