mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 07:33:47 +08:00
mod
This commit is contained in:
309
极客时间专栏/跟月影学可视化/视觉基础篇/10 | 图形系统如何表示颜色?.md
Normal file
309
极客时间专栏/跟月影学可视化/视觉基础篇/10 | 图形系统如何表示颜色?.md
Normal file
@@ -0,0 +1,309 @@
|
||||
<audio id="audio" title="10 | 图形系统如何表示颜色?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/fe/5c56781324400fc6bceb028acbc715fe.mp3"></audio>
|
||||
|
||||
你好,我是月影。从这一节课开始,我们进入一个全新的模块,开始学习视觉基础。
|
||||
|
||||
在可视化领域中,图形的形状和颜色信息非常重要,它们都可以用来表达数据。我们利用基本的数学方法可以绘制出各种各样的图形,通过仿射变换还能改变图形的形状、大小和位置。但关于图形的颜色,虽然在前面的课程中,我们也使用片元着色器给图形设置了不同的颜色,可这只是颜色的基本用法,Web图形系统对颜色的支持是非常强大的。
|
||||
|
||||
所以这一节课,我们就来系统地学习一下,Web图形系统中表示颜色的基本方法。我会讲四种基本的颜色表示法,分别是RGB和RGBA颜色表示法、HSL和HSV颜色表示法、CIE Lab和CIE Lch颜色表示法以及Cubehelix色盘。
|
||||
|
||||
不过,因为颜色表示实际上是一门非常复杂的学问,与我们自己的视觉感知以及心理学都有很大的关系,所以这节课我只会重点讲解它们的应用,不会去细说其中复杂的算法实现和规则细节。但我也会在课后给出一些拓展阅读的链接,如果你有兴趣,可以利用它们深入来学。
|
||||
|
||||
## RGB和RGBA颜色
|
||||
|
||||
作为前端工程师,你一定对RGB和RGBA颜色比较熟悉。在Web开发中,我们首选的颜色表示法就是RGB和RGBA。那我们就先来说说它的应用。
|
||||
|
||||
### 1. RGB和RGBA的颜色表示法
|
||||
|
||||
我们在CSS样式中看到的形式如#RRGGBB的颜色代码,就是RGB颜色的十六进制表示法,其中RR、GG、BB分别是两位十六进制数字,表示红、绿、蓝三色通道的**色阶**。色阶可以表示某个通道的强弱。
|
||||
|
||||
因为RGB(A)颜色用两位十六进制数来表示每一个通道的色阶,所以每个通道一共有256阶,取值是0到255。RGB的三个通道色阶的组合,理论上一共能表示2<sup>24</sup> 也就是一共16777216种不同的颜色。因此,RGB颜色是将人眼可见的颜色表示为**红、绿、蓝**三原色不同色阶的混合。我们可以用一个三维立方体,把RGB能表示的所有颜色形象地描述出来。效果如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/70/5ff37612dff2e7a89c58fcdc91236270.jpg" alt="" title="RGB的所有颜色">
|
||||
|
||||
那RGB能表示人眼所能见到的所有颜色吗?事实上,RGB色值只能表示这其中的一个区域。如下图所示,灰色区域是人眼所能见到的全部颜色,中间的三角形是RGB能表示的所有颜色,你可以明显地看出它们的对比。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/6d/950d5507a41978byyd28d32bb81e736d.jpg" alt="" title="人眼看到的颜色vsRGB能表示的颜色">
|
||||
|
||||
尽管RGB色值不能表示人眼可见的全部颜色,但它可以 表示的颜色也已经足够丰富了。一般的显示器、彩色打印机、扫描仪等都支持它。
|
||||
|
||||
在浏览器中,CSS一般有两种表示RGB颜色值的方式:一种是我们前面说的#RRGGBB表示方式,另一种是直接用rgb(red, green, blue)表示颜色,这里的“red、green、blue”是十进制数值。RGB颜色值的表示方式,你应该比较熟悉,我就不多说了。
|
||||
|
||||
好,理解了RGB之后,我们就很容易理解RGBA了。它其实就是在RGB的基础上增加了一个Alpha通道,也就是透明度。一些新版本的浏览器,可以用#RRGGBBAA的形式来表示RGBA色值,但是较早期的浏览器,只支持rgba(red, green, blue, alpha)这种形式来表示色值(注意:这里的alpha是一个从0到1的数)。所以,在实际使用的时候,我们要注意这一点。
|
||||
|
||||
WebGL的shader默认支持RGBA。因为在WebGL的shader中,我们是使用一个四维向量来表示颜色的,向量的r、g、b、a分量分别表示红色、绿色、蓝色和alpha通道。不过和CSS的颜色表示稍有不同的是,WebGL采用归一化的浮点数值,也就是说,WebGL的颜色分量r、g、b、a的数值都是0到1之间的浮点数。
|
||||
|
||||
### 2. RGB颜色表示法的局限性
|
||||
|
||||
RGB和RGBA的颜色表示法非常简单,但使用起来也有局限性(因为RGB和RGBA本质上其实非常相似,只不过后者比前者多了一个透明度通道。方便起见,我们后面就用RGB来代表RGB和RGBA了)。
|
||||
|
||||
因为对一个RGB颜色来说,我们只能大致直观地判断出它偏向于红色、绿色还是蓝色,或者在颜色立方体的大致位置。所以,在对比两个RGB颜色的时候,我们只能通过对比它们在RGB立方体中的相对距离,来判断它们的颜色差异。除此之外,我们几乎就得不到其他任何有用的信息了。
|
||||
|
||||
也就是说,**当要选择一组颜色给图表使用时,我们并不知道要以什么样的规则来配置颜色,才能让不同数据对应的图形之间的对比尽可能鲜明**。因此,RGB颜色对用户其实并不友好。
|
||||
|
||||
这么说可能还是比较抽象,我们来看一个简单的例子。这里,我们在画布上显示3组颜色不同的圆,每组各5个,用来表示重要程度不同的信息。现在我们给这些圆以随机的RGB颜色,代码如下:
|
||||
|
||||
```
|
||||
import {Vec3} from '../common/lib/math/vec3.js';
|
||||
const canvas = document.querySelector('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
function randomRGB() {
|
||||
return new Vec3(
|
||||
0.5 * Math.random(),
|
||||
0.5 * Math.random(),
|
||||
0.5 * Math.random(),
|
||||
);
|
||||
}
|
||||
|
||||
ctx.translate(256, 256);
|
||||
ctx.scale(1, -1);
|
||||
|
||||
for(let i = 0; i < 3; i++) {
|
||||
const colorVector = randomRGB();
|
||||
for(let j = 0; j < 5; j++) {
|
||||
const c = colorVector.clone().scale(0.5 + 0.25 * j);
|
||||
ctx.fillStyle = `rgb(${Math.floor(c[0] * 256)}, ${Math.floor(c[1] * 256)}, ${Math.floor(c[2] * 256)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过执行上面的代码,我们生成随机的三维向量,然后将它转成RGB颜色。为了保证对比,我们在每一组的5个圆中,依次用0.5、0.75、1.0、1.25和1.5的比率乘上我们随机生成的RGB数值。这样,一组圆就能呈现不同的亮度了。总体上颜色是越左边的越暗,越右边的越亮。得到的效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/a8/9f8af87f3af968e8e70d0ee09a8a8da8.jpeg" alt="">
|
||||
|
||||
但是,这么做有两个缺点:首先,因为这个例子里的RGB颜色是随机产生的,所以行与行之间的颜色差别可能很大,也可能很小,我们无法保证具体的颜色差别大小;其次,因为无法控制随机生成的颜色本身的亮度,所以这样生成的一组圆的颜色有可能都很亮或者都很暗。比如,下图中另一组随机生成的圆,除了第一行外,后面两行的颜色都很暗,区分度太差。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/39/334f2162ab2fc98cae325feacaf6d639.jpeg" alt="">
|
||||
|
||||
因此,在需要**动态构建视觉颜色效果**的时候,我们很少直接选用RGB色值,而是使用其他的颜色表示形式。这其中,比较常用的就是HSL和HSV颜色表示形式。
|
||||
|
||||
## HSL和HSV颜色
|
||||
|
||||
与RGB颜色以色阶表示颜色不同,HSL和HSV用色相(Hue)、饱和度(Saturation)和亮度(Lightness)或明度(Value)来表示颜色。其中,Hue是角度,取值范围是0到360度,饱和度和亮度/明度的值都是从0到100%。
|
||||
|
||||
虽然HSL和HSV在[表示方法](https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4#/media/File:Hsl-hsv_models.svg)上有一些区别,但它们能达到的效果比较接近。所以就目前来说,我们并不需要深入理解它们之间的区别,只要学会HSL和HSV通用的颜色表示方法就可以了。
|
||||
|
||||
### 1. HSL和HSV的颜色表示方法
|
||||
|
||||
HSL和HSV是怎么表示颜色的呢?实际上,我们可以把HSL和HSV颜色理解为,是将RGB颜色的立方体从直角坐标系投影到极坐标的圆柱上,所以它的色值和RGB色值是一一对应的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/22/79400fe9ded6298b3d7d13f91a64ac22.jpg" alt="" title="HSL和HSV的产生原理">
|
||||
|
||||
从上图中,你可以发现,它们之间色值的互转算法比较复杂。不过好在,CSS和Canvas2D都可以直接支持HSL颜色,只有WebGL需要做转换。所以,如果你有兴趣深入了解这个转换算法,可以去看一下我课后给出的推荐阅读。那在这里,你只需要记住我下面给出的这一段RGB和HSV的转换代码就可以了,后续课程中我们会用到它。
|
||||
|
||||
```
|
||||
vec3 rgb2hsv(vec3 c){
|
||||
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
|
||||
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
|
||||
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
|
||||
float d = q.x - min(q.w, q.y);
|
||||
float e = 1.0e-10;
|
||||
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
|
||||
}
|
||||
|
||||
vec3 hsv2rgb(vec3 c){
|
||||
vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
|
||||
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
|
||||
return c.z * mix(vec3(1.0), rgb, c.y);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好,记住了转换代码之后。下面,我们直接用HSL颜色改写前面绘制三排圆的例子。这里,我们只要把代码稍微做一些调整。
|
||||
|
||||
```
|
||||
function randomColor() {
|
||||
return new Vec3(
|
||||
0.5 * Math.random(), // 初始色相随机取0~0.5之间的值
|
||||
0.7, // 初始饱和度0.7
|
||||
0.45, // 初始亮度0.45
|
||||
);
|
||||
}
|
||||
|
||||
ctx.translate(256, 256);
|
||||
ctx.scale(1, -1);
|
||||
|
||||
const [h, s, l] = randomColor();
|
||||
for(let i = 0; i < 3; i++) {
|
||||
const p = (i * 0.25 + h) % 1;
|
||||
for(let j = 0; j < 5; j++) {
|
||||
const d = j - 2;
|
||||
ctx.fillStyle = `hsl(${Math.floor(p * 360)}, ${Math.floor((0.15 * d + s) * 100)}%, ${Math.floor((0.12 * d + l) * 100)}%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,我们生成随机的HSL颜色,主要是随机色相H,然后我们将H值的角度拉开,就能保证三组圆彼此之间的颜色差异比较大。
|
||||
|
||||
接着,我们增大每一列圆的饱和度和亮度,这样每一行圆的亮度和饱和度就都不同了。但要注意的是,我们要同时增大亮度和饱和度。因为根据HSL的规则,亮度越高,颜色越接近白色,只有同时提升饱和度,才能确保圆的颜色不会太浅。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/a6/68031c6964650bc2f31def0f93fda2a6.jpeg" alt="">
|
||||
|
||||
### 2. HSL和HSV的局限性
|
||||
|
||||
不过,从上面的例子中你也可以看出来,即使我们可以均匀地修改每组颜色的亮度和饱和度,但这样修改之后,有的颜色看起来和其他的颜色差距明显,有的颜色还是没那么明显。这是为什么呢?这里我先卖个关子,我们先来做一个简单的实验。
|
||||
|
||||
```
|
||||
for(let i = 0; i < 20; i++) {
|
||||
ctx.fillStyle = `hsl(${Math.floor(i * 15)}, 50%, 50%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc((i - 10) * 20, 60, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
for(let i = 0; i < 20; i++) {
|
||||
ctx.fillStyle = `hsl(${Math.floor((i % 2 ? 60 : 210) + 3 * i)}, 50%, 50%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,我们绘制两排不同的圆,让第一排每个圆的色相间隔都是15,再让第二排圆的颜色在色相60和210附近两两交错。然后,我们让这两排圆的饱和度和亮度都是50%,最终生成的效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/61/674ae06ae45050bb2e9840a1c081b661.jpeg" alt="">
|
||||
|
||||
先看第一排圆你会发现,虽然它们的色相相差都是15,但是相互之间颜色变化并不是均匀的,尤其是中间几个绿色圆的颜色比较接近。接着我们再看第二排圆,虽然这些圆的亮度都是50%,但是蓝色和紫色的圆看起来就是不如偏绿偏黄的圆亮。这都是由于人眼对不同频率的光的敏感度不同造成的。
|
||||
|
||||
因此,HSL依然不是最完美的颜色方法,我们还需要建立一套针对人类知觉的标准,这个标准在描述颜色的时候要尽可能地满足以下2个原则:
|
||||
|
||||
1. 人眼看到的色差 = 颜色向量间的欧氏距离
|
||||
1. 相同的亮度,能让人感觉亮度相同
|
||||
|
||||
于是,一个针对人类感觉的颜色描述方式就产生了,它就是CIE Lab。
|
||||
|
||||
## CIE Lab和CIE Lch颜色
|
||||
|
||||
CIE Lab颜色空间简称Lab,它其实就是一种符合人类感觉的色彩空间,它用L表示亮度,a和b表示颜色对立度。RGB值也可以Lab转换,但是转换规则比较复杂,你可以通过[wikipedia.org](https://en.wikipedia.org/wiki/CIELAB_color_space)来进一步了解它的基本原理。
|
||||
|
||||
CIE Lab比较特殊的一点是,目前还没有能支持CIE Lab的图形系统,但是[css-color level4](https://www.w3.org/TR/css-color-4/#funcdef-lab)规范已经给出了Lab颜色值的定义。
|
||||
|
||||
```
|
||||
lab() = lab( <percentage> <number> <number> [ / <alpha-value> ]? )
|
||||
|
||||
```
|
||||
|
||||
而且,一些JavaScript库也已经可以直接处理Lab颜色空间了,如[d3-color](https://github.com/d3/d3-color)。下面,我们通过一个代码例子来详细讲讲,d3.lab是怎么处理Lab颜色的。如下面代码所示,我们使用d3.lab来定义Lab色彩。这个例子与HSL的例子一样,也是显示两排圆形。这里,我们让第一排相邻圆形之间的lab色值的欧氏空间距离相同,第二排相邻圆形之间的亮度按5阶的方式递增。
|
||||
|
||||
```
|
||||
/* global d3 */
|
||||
for(let i = 0; i < 20; i++) {
|
||||
const c = d3.lab(30, i * 15 - 150, i * 15 - 150).rgb();
|
||||
ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc((i - 10) * 20, 60, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
for(let i = 0; i < 20; i++) {
|
||||
const c = d3.lab(i * 5, 80, 80).rgb();
|
||||
ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码最终的运行效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/92/9c0898db90d53fe4fd3e6b3c83043d92.jpeg" alt="">
|
||||
|
||||
你会发现,在以CIELab方式呈现的色彩变化中,我们设置的数值和人眼感知的一致性比较强。
|
||||
|
||||
而CIE Lch和CIE Lab的对应方式类似于RGB和HSL和HSV的对应方式,也是将坐标从立方体的直角坐标系变换为圆柱体的极坐标系,这里就不再多说了。CIE Lch和CIE Lab表示颜色的技术还比较新,所以目前我们也不会接触很多,但是因为它能呈现的色彩更贴近人眼的感知,所以我相信它会发展得很快。作为技术人,这些新技术,我们也要持续关注。
|
||||
|
||||
## Cubehelix色盘
|
||||
|
||||
最后,我们再来说一种特殊的颜色表示法,Cubehelix色盘(立方螺旋色盘)。简单来说,它的原理就是在RGB的立方中构建一段螺旋线,让色相随着亮度增加螺旋变换。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/0c/281b8bbdeebyy0f3e62c80278267150c.jpg" alt="" title="Cubehelix色盘的原理">
|
||||
|
||||
我们还是直接来看它的应用。接下来,我会直接用NPM上的[cubehelix](https://www.npmjs.com/package/cubehelix)模块写一个颜色随着长度变化的柱状图,你可以通过它来看看Cubehelix是怎么应用的。效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/ef/4d71b84fc3282e84a98141e444f658ef.gif" alt="">
|
||||
|
||||
它的实现代码也非常简单,我来简单说一下思路。
|
||||
|
||||
首先,我们直接使用cubehelix函数创建一个color映射。cubehelix函数是一个高阶函数,它的返回值是一个色盘映射函数。这个返回函数的参数范围是0到1,当它从小到大依次改变的时候,不仅颜色会依次改变,亮度也会依次增强。然后,我们用正弦函数来模拟数据的周期性变化,通过color§获取当前的颜色值,再把颜色值赋给ctx.fillStyle,颜色就能显示出来了。最后,我们用rect将柱状图画出来,用requestAnimationFrame实现动画就可以了 。
|
||||
|
||||
```
|
||||
import {cubehelix} from 'cubehelix';
|
||||
|
||||
const canvas = document.querySelector('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.translate(0, 256);
|
||||
ctx.scale(1, -1);
|
||||
|
||||
const color = cubehelix(); // 构造cubehelix色盘颜色映射函数
|
||||
const T = 2000;
|
||||
|
||||
function update(t) {
|
||||
const p = 0.5 + 0.5 * Math.sin(t / T);
|
||||
ctx.clearRect(0, -256, 512, 512);
|
||||
const {r, g, b} = color(p);
|
||||
ctx.fillStyle = `rgb(${255 * r},${255 * g},${255 * b})`;
|
||||
ctx.beginPath();
|
||||
ctx.rect(20, -20, 480 * p, 40);
|
||||
ctx.fill();
|
||||
window.ctx = ctx;
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
update(0);
|
||||
|
||||
```
|
||||
|
||||
到这里,我们关于颜色表示的讨论就告一段落了。这4种颜色方式的具体应用你应该已经掌握了,那我再来说说在实际工作中,它们的具体使用场景,这样你就能记得更深刻了。
|
||||
|
||||
在可视化应用里,一般有两种使用颜色的方式:第一种,整个项目的UI配色全部由UI设计师设计好,提供给可视化工程师使用。那在这种情况下,设计师设计的颜色是多少就是多少,开发者使用任何格式的颜色都行。第二种方式就是根据数据情况由前端动态地生成颜色值。当然不会是整个项目都由开发者完全自由选择,而一般是由设计师定下视觉基调和一些主色,开发者根据主色和数据来生成对应的颜色。
|
||||
|
||||
在一般的图表呈现项目中,第一种方式使用较多。而在一些数据比较复杂的项目中,我们经常会使用第二种方式。尤其是当我们希望连续变化的数据能够呈现连续的颜色变换时,设计师就很难用预先指定的有限的颜色来表达了。这时候,我们就需要使用其他的方式,比如,HLS、CIELab或者Cubehelix色盘,我们会把它们结合数据变量来动态生成颜色值。
|
||||
|
||||
## 要点总结
|
||||
|
||||
这一节课,我们系统地学习了Web图形系统表示颜色的方法。它们可以分为2大类,分别是RGB、HSL和HSV、CIELab和CIELch等颜色空间的表示方法,以及Cubehelix色盘的表示方法。
|
||||
|
||||
首先,RGB用三原色的色阶来表示颜色,是最基础的颜色表示法,但是它对用户不够友好。而HSL和HSV是用色相、饱和度、亮度(明度)来表示颜色,对开发者比较友好,但是它的数值变换与人眼感知并不完全相符。
|
||||
|
||||
CIELab和CIELch与Cubehelix色盘,这两种颜色表示法还比较新,在实际工作中使用得不是很多。其中,CIELab和CIELch是与人眼感知相符的色彩空间表示法,已经被纳入css-color level4规范中。虽然还没有被浏览器支持,但是一些如d3-color这样的JavaScript库可以直接处理Lab颜色空间。而如果我们要呈现颜色随数据动态改变的效果,那Cubehelix色盘就是一种非常更合适的选择了。
|
||||
|
||||
最后,我还想再啰嗦几句。在可视化中,我们会使用图形的大小、高低、宽窄、颜色和形状这些常见信息来反映数据。一般来说,我们会使用一种叫做二维强化的技巧,来叠加两个维度的信息,从而加强可视化的视觉呈现效果。
|
||||
|
||||
比如,柱状图的高低表示了数据的多少,但是如果这个数据非常重要,那么我们在给柱状图设置不同高低的同时,再加上颜色的变化,就能让这个柱状图更具视觉冲击力。这也是我们必须要学会熟练运用颜色的原因。
|
||||
|
||||
所以,颜色的使用在可视化呈现中非常重要,在之后的课程中,我们还会继续深入探讨颜色的用法。
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
我在课程中给出了hsv和rgb互转的glsl代码。你能尝试用WebGL画两个圆,让它们的角度对应具体的HUE色相值,让其中一个圆的半径对应饱和度S,另一个圆的半径对应明度V,将HSV色盘绘制出来吗?
|
||||
|
||||
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
|
||||
|
||||
## 源码
|
||||
|
||||
[本节课示例代码完整版](https://github.com/akira-cn/graphics/tree/master/color-hints)
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
颜色也是可视化非常重要的内容,所以这节课的知识点比较多,参考资料也很多。如果你有兴趣深入研究,我建议你一定要认真看看我给的这些资料。
|
||||
|
||||
[1] [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4)<br>
|
||||
[2] [RGB color model](https://www.wikiwand.com/en/RGB_color_model)<br>
|
||||
[3] [HSL和HSV色彩空间](https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4)<br>
|
||||
[4] [色彩空间中的 HSL、HSV、HSB的区别](https://www.zhihu.com/question/22077462)<br>
|
||||
[5] [用JavaScript实现RGB-HSL-HSB相互转换的方法](http://wsyks.github.io/2017/03/17/JS%E5%AE%9E%E7%8E%B0RGB-HSL-HSB%E7%9B%B8%E4%BA%92%E8%BD%AC%E6%8D%A2/)<br>
|
||||
[6] [Lab色彩空间维基百科](https://zh.wikipedia.org/wiki/Lab%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4)<br>
|
||||
[7] [Cubehelix颜色表算法](https://zhuanlan.zhihu.com/p/121055691)<br>
|
||||
[8] [Dave Green’s `cubehelix’ colour scheme](http://www.mrao.cam.ac.uk/~dag/CUBEHELIX/)<br>
|
||||
[9] [d3-color官方文档](https://d3js.org.cn/document/d3-color)
|
||||
473
极客时间专栏/跟月影学可视化/视觉基础篇/11|图案生成:如何生成重复图案、分形图案以及随机效果?.md
Normal file
473
极客时间专栏/跟月影学可视化/视觉基础篇/11|图案生成:如何生成重复图案、分形图案以及随机效果?.md
Normal file
@@ -0,0 +1,473 @@
|
||||
<audio id="audio" title="11|图案生成:如何生成重复图案、分形图案以及随机效果?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/b4/yy9cf73e08749e962f1a96da9a037ab4.mp3"></audio>
|
||||
|
||||
你好,我是月影。
|
||||
|
||||
图案生成是可视化中非常重要的基础。有多重要呢?我们知道,可视化中的几何图形是用来表达数据的,那图案就是用来修饰这些几何图形,强化视觉效果的,所以图案一般是指几何图形上的花纹。这些花纹有的简单,有的复杂,有的规律明显,有的看上去比较随机。也正是因为图案可以如此的不同,它们才能更好地增强视觉效果。
|
||||
|
||||
这一节课,我们就来聊一聊图案生成的基本原理和方法论。不过,因为可视化中的图案非常多,所以今天我们主要来讲三种最常用的,分别是重复图案、分形图案和随机图案。
|
||||
|
||||
首先,我们来看重复图案。
|
||||
|
||||
## 如何绘制大批量重复图案
|
||||
|
||||
在可视化应用中,我们经常会使用重复图案。比如说,我们在显示图表的时候,经常会给背景加上一层网格,这样可以辅助用户阅读和理解图表数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/7a/6169388ac17a338ab1bf8b40d93c657a.jpeg" alt="" title="带有网格背景的画布">
|
||||
|
||||
那像网格这样经典的重复图案,我们应该怎样绘制它呢?这些网格看起来像是由一条一条线段组成的,是不是利用绘制线段的方式,比如我们之前学过的Canvas2D的绘图指令来绘制就可以了?如果你是这么想的,就把问题想得太简单了。
|
||||
|
||||
举个例子,如果我们将网格绘制在Canvas2D画布上,那网格的线条就会很多,这也就意味着我们要用大量的绘图指令来绘制。这个时候,一旦Canvas2D的画面改变了,我们就需要重绘全部的网格,这会大大消耗系统的性能。而且,如果将大量的时间都浪费在绘制这种重复图案上,那我们实现的代码性能可能就会很差。
|
||||
|
||||
那我们该怎么办呢?你可能会想到准备两个Canvas2D画布,一个用来绘制网格,另一个用来绘制其他会变化的图形。能想到这个办法还是不错的,说明你动了脑筋,它确实解决了图案重绘的问题。不过,我们第一次绘图的开销仍然存在。因此,我们的解决思路不能局限在使用Canvas2D的绘图指令上。
|
||||
|
||||
### 1. 使用background-image来绘制重复图案
|
||||
|
||||
我们有更巧妙的办法来“绘制”这种网格图案,那就是使用CSS的background-image属性。代码如下:
|
||||
|
||||
```
|
||||
canvas {
|
||||
background-image: linear-gradient(to right, transparent 90%, #ccc 0),
|
||||
linear-gradient(to bottom, transparent 90%, #ccc 0);
|
||||
background-size: 8px 8px, 8px 8px;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
以防你对CSS的linear-gradient属性还不太熟悉,我这里简单解释一下它。CSS的linear-gradient属性可以定义线性渐变,在这个例子里,to right 表示颜色过渡是从左到右的,其中0%到90%的区域是透明的,90%到100%的区域是#ccc颜色。另外,在linear-gradient中定义颜色过渡的时候,如果后一个过渡颜色的区域值和前面相同,我们可以把它简单写为0。
|
||||
|
||||
因为浏览器将渐变属性视为图片,所以我们可以将渐变设置在任何可以接受图片的CSS属性上。在这里,我们就可以把渐变设置在background-image上,也就是作为背景色来使用。
|
||||
|
||||
如上面的代码所示,我们一共给background-image设置了两个linear-gradient,一个是横向的(to right),一个是纵向的(to bottom)。因为css的background-repeat默认值是repeat,所以我们给背景设置一下background-size。这样,我们利用浏览器自己的background-repeat机制,就可以实现我们想要的网格背景了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/92/077771d23ac719dc2d4dd10552b41192.jpeg" alt="" title="网格图案效果">
|
||||
|
||||
总结来说,这种利用了CSS属性设置重复网格背景的技巧,在一般情况下能够满足我们的需要,但也会有一些限制。首先,因为它设置的是Canvas元素的背景,所以它和直接绘制在画布上的其他图形就处于不同的层,我们也就没法将它覆盖在这些图形上了。其次,当我们用坐标变换来缩放或移动元素时,作为元素背景的网格是不会随着缩放或移动而改变的。
|
||||
|
||||
### 2. 使用Shader来绘制重复图案
|
||||
|
||||
那如果是用WebGL来渲染的话,我们还有更简单的做法,就是利用GPU并行计算的特点,使用着色器来绘制背景网格这样的重复图案。
|
||||
|
||||
这里,我直接给出了顶点着色器和片元着色器中的代码,你可以看看。
|
||||
|
||||
```
|
||||
//顶点着色器:
|
||||
|
||||
attribute vec2 a_vertexPosition;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
|
||||
|
||||
void main() {
|
||||
gl_PointSize = 1.0;
|
||||
vUv = uv;
|
||||
gl_Position = vec4(a_vertexPosition, 1, 1);
|
||||
|
||||
|
||||
//片元着色器:
|
||||
|
||||
|
||||
#ifdef GL_ES
|
||||
precision mediump float;
|
||||
#endif
|
||||
varying vec2 vUv;
|
||||
uniform float rows;
|
||||
|
||||
void main() {
|
||||
vec2 st = fract(vUv * rows);
|
||||
float d1 = step(st.x, 0.9);
|
||||
float d2 = step(0.1, st.y);
|
||||
gl_FragColor.rgb = mix(vec3(0.8), vec3(1.0), d1 * d2);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
那这两段Shader代码的具体行为是什么呢?你可以先自己想一想,这里我先卖个关子,一会儿再详细解释,我们先来看看WebGL绘制重复图案的过程。
|
||||
|
||||
我们知道,直接用WebGL来绘图比较繁琐,所以从这一节课开始,我们不采用原生的底层WebGL绘图了,而是采用一个基础库[gl-renderer](https://github.com/akira-cn/gl-renderer)。gl-renderer在WebGL底层的基础上进行了一些简单的封装,以便于我们将重点放在提供几何数据、设置变量和编写Shader上,不用因为创建buffer等细节而分心。
|
||||
|
||||
gl-renderer的使用方法十分简单,基本上和第4节课WebGL三角形的过程一致,一共分为五步,唯一的区别是gl-renderer对每一步的代码进行了封装。我把这五步都列出来了,我们一起来看看。
|
||||
|
||||
**步骤一和步骤二分别是创建Renderer对象和创建并启用WebGL程序**,过程非常简单,你直接看我给出的代码就可以理解了。
|
||||
|
||||
```
|
||||
//第一步:
|
||||
const canvas = document.querySelector('canvas');
|
||||
const renderer = new GlRenderer(canvas);
|
||||
|
||||
//第二步:
|
||||
const program = renderer.compileSync(fragment, vertex);
|
||||
renderer.useProgram(program);
|
||||
|
||||
```
|
||||
|
||||
步骤三和步骤四是最核心的两个步骤,我来重点说说。
|
||||
|
||||
**步骤三是设置uniform变量。**这里,我们设置了一个rows变量,表示每一行显示多少个网格。然后我们会在片元着色器中使用它。
|
||||
|
||||
```
|
||||
renderer.uniforms.rows = 64;
|
||||
|
||||
```
|
||||
|
||||
**步骤四是将顶点数据送入缓冲区。**
|
||||
|
||||
```
|
||||
renderer.setMeshData([{
|
||||
positions: [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
],
|
||||
attributes: {
|
||||
uv: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
],
|
||||
},
|
||||
cells: [[0, 1, 2], [2, 0, 3]],
|
||||
}]);
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,我们一共设置了三个数据。首先,我们设置了positions也就是顶点。这里我们一共设置了四个顶点,这四个顶点坐标正好覆盖了整个Canvas画布。接着是uv,也就是**纹理坐标**。它和纹理设置有关,不过你先不用理解什么是纹理设置,只要知道这个坐标系的左下角为0,0,右上角为1,1就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/07/756d265d782a6d9a706db049ayy4f607.jpeg" alt="" title="顶点坐标和uv坐标">
|
||||
|
||||
第三个是cells,顶点索引。我们知道,WebGL只能渲染经过三角剖分之后的多边形。那利用cells: [(0, 1, 2), (2, 0, 3)],我们就能将这个矩形画布剖分成两个三角形,这两个三角形的顶点下标分别是(0, 1, 2)和(2, 0, 3)。
|
||||
|
||||
最后,我们将顶点送入缓冲区后,执行renderer.render()渲染,网格就被渲染出来了。
|
||||
|
||||
接下来,我们重点看一下片元着色器中的代码,来理解一下渲染过程。
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = fract(vUv * rows);
|
||||
float d1 = step(st.x, 0.9);
|
||||
float d2 = step(0.1, st.y);
|
||||
gl_FragColor.rgb = mix(vec3(0.8), vec3(1.0), d1 * d2);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,我们要获得重复的rows行rows列的值st。这里我们要用到一个函数fract,它在Shader中非常常用,可以用来获取一个数的小数部分。当一个数从0~1周期性变化的时候, 我们只要将它乘以整数N,然后再用fract取小数,就能得到N个周期的数值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/27/55ff744280b589147b8b7f1f5acdf527.jpeg" alt="" title="y = fract(x) 在整数区间内周期重复示意图">
|
||||
|
||||
所以,这里我们用vUv也就是由顶点着色器传来的uv属性(纹理坐标)乘上rows值,然后用fract取小数部分,就能得到st了。
|
||||
|
||||
接着,我们处理st的x和y。因为WebGL中的片元着色器线性插值,所以现在它们默认是线性变化的,而我们要的是阶梯变化。那要实现阶梯变化,我们可以使用step函数,step函数是Shader中另一个很常用的函数,它就是一个阶梯函数。它的原理是:当step(a, b)中的b < a时,返回0;当b >= a时,返回1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/52/6dcd4de27bb6753274814a6b0e8c8852.jpeg" alt="" title="Step函数">
|
||||
|
||||
因此,d1和d2分别有2种取值情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/5e/9baa897a9180ce776b72cb90a8cfd45e.jpg" alt="">
|
||||
|
||||
最后,我们要根据d1 * d2的值,决定背景网格使用哪个颜色来绘制。要实现这个目的,我们就要使用到第三个函数mix。mix是线性插值函数,mix(a, b, c)表示根据c是0或1,返回a或者b。
|
||||
|
||||
比如在上面的代码中,当st.x小于0.9且st.y大于0.1,也就是d1 * d2等于1的时候,mix(vec3(0.8), vec3(1.0), d1 * d2) 的结果是vec3(1.0),也就是白色。否则就是vec3(0.8),也就是灰色。
|
||||
|
||||
最后,因为rows决定网格重复的次数,所以最终的效果和rows的取值有关。为了让你有更直观的感受,我把row分别取1、4、16、32、64时的效果都绘制出来了,你可以看看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/e6/19fd5561b5ac779f1de9d49c4d8bbbe6.gif" alt="" title="rows为1、4、16、32、64的效果">
|
||||
|
||||
这就是我们用Shader实现重复图案的完整过程。它的优势在于,不管我们给rows取值多少,图案都是一次绘制出来的,并不会因为rows增加而消耗性能。所以,使用Shader绘制重复图案,不管绘制多么细腻,图案重复多少次,绘制消耗的时间几乎是常量,不会遇到性能瓶颈。
|
||||
|
||||
## 如何绘制分形图案
|
||||
|
||||
说完了重复图案,我们再来说分形。它不仅是自然界中存在的一种自然现象,也是一种优美的数学模型。通俗点来说,一个分形图案可以划分成无数个部分,而每个部分的形状又都和这个图案整体具有相似性。所以,典型的分形效果具有局部与整体的自相似性以及无限细节(分形可以无限放大),能产生令人震撼的视觉效果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/64/341085ab7bf1076255a3d151e563cc64.jpg" alt="" title="自然界中的分形:罗马花椰菜">
|
||||
|
||||
实际上,分形在实践中偏向于视觉和UI设计。虽然它在实际的可视化项目中不太常用,但总能够起到画龙点睛的作用。所以,了解分形在视觉呈现中的实现技巧还是很有必要的。下面,我们就来详细讲讲分形是怎么实现的。
|
||||
|
||||
首先,我们来认识一下分形公式,Mandelbrot Set,也叫曼德勃罗特集。它是由美国数学家曼徳勃罗特教授发现的迭代公式构成的分形集合。这个公式中Z<sub>n</sub>和Z<sub>n+1</sub>是复数,C是一个实数常量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/77/db02b5eddd8721e9f5299853ace9d077.jpeg" alt="">
|
||||
|
||||
这个迭代公式使用起来非常简单,只要我们给定一个初始值,它就能产生许多有趣的图案。接下来,我们就一起来看一个有趣的例子。
|
||||
|
||||
首先我们实现一个片元着色器,代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision mediump float;
|
||||
#endif
|
||||
varying vec2 vUv;
|
||||
uniform vec2 center;
|
||||
uniform float scale;
|
||||
|
||||
vec2 f(vec2 z, vec2 c) {
|
||||
return mat2(z, -z.y, z.x) * z + c;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
vec2 c = center + 4.0 * (uv - vec2(0.5)) / scale;
|
||||
vec2 z = vec2(0.0);
|
||||
|
||||
bool escaped = false;
|
||||
int j;
|
||||
for (int i = 0; i < 65536; i++) {
|
||||
if(i > iterations) break;
|
||||
j = i;
|
||||
z = f(z, c);
|
||||
if (length(z) > 2.0) {
|
||||
escaped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor.rgb = escaped ? vec3(float(j)) / float(iterations) : vec3(0.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们设置了初始的z和c,然后执行迭代。理论上曼德勃罗特集应该是无限迭代的,但是我们肯定不能让它无限循环,所以我们要给一个足够精度的最大迭代次数,比如65536。在迭代过程中,如果z的模大于2,那它就结束计算,否则就继续迭代,直到达到循环次数。
|
||||
|
||||
我们把(0, 0)设置为图案中心点,放大系数初始设为1,即原始大小,然后开始渲染,代码如下:
|
||||
|
||||
```
|
||||
const program = renderer.compileSync(fragment, vertex);
|
||||
renderer.useProgram(program);
|
||||
renderer.uniforms.center = [0, 0];
|
||||
renderer.uniforms.scale = 1;
|
||||
renderer.uniforms.iterations = 256;
|
||||
|
||||
renderer.setMeshData([{
|
||||
positions: [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
],
|
||||
attributes: {
|
||||
uv: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
],
|
||||
},
|
||||
cells: [[0, 1, 2], [2, 0, 3]],
|
||||
}]);
|
||||
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/5a/0d33586fd494f7ba55cfe17a7e9e105a.jpeg" alt="" title="画布上最终的渲染结果">
|
||||
|
||||
这个图案本身似乎没有什么特别的效果,我们可以修改一下Shader中的代码,改变渲染颜色的规则,根据迭代次数和迭代深度的比值来渲染不同的颜色,然后将它局部放大,就能得到非常有趣的图案了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/9e/b61a71e0de654958d1ee033b3b0a939e.gif" alt="">
|
||||
|
||||
## 如何给图案增加随机效果
|
||||
|
||||
那分形图案为什么这么吸引人呢?如果你多看几个,就会发现,它们的无限细节里同时拥有重复和随机这两个规律。那对于其他非分形的图案,如果也想让它变得吸引人,我们其实可以给它们增加**随机效果**。
|
||||
|
||||
不知道,你还记得我们开篇词中的那个抽奖程序吗?实际上它就是一个随机效果的应用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/eb/e7025fcba897df3b78205d1d711cb9eb.jpg" alt="" title="产生随机色块的抽奖程序">
|
||||
|
||||
要想实现类似这样的随机效果,在Shader中,我们可以使用伪随机函数。下面,我以一个常用的伪随机函数为例,来讲讲随机效果是怎么生成的。代码如下:
|
||||
|
||||
```
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个伪随机函数的原理是,取正弦函数偏后部的小数部分的值来模拟随机。如果我们传入一个确定的st值,它就会返回一个符合随机分布的确定的float值。
|
||||
|
||||
我们可以测试一下这个伪随机函数,代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
void main() {
|
||||
gl_FragColor.rgb = vec3(random(vUv));
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
它的执行结果是一片噪点,效果如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/22/65539f5eda08e26a22caaf84a66f5822.jpeg" alt="">
|
||||
|
||||
这些噪点显然不能满足我们想要的随机效果,因为它们只有一个像素,而且太小了。所以下一步,我们可以用floor取整函数,来生成随机的色块。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * 10.0;
|
||||
gl_FragColor.rgb = vec3(random(floor(st)));
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
floor函数和JavaScript的Math.floor一样,都是向下取浮点数的整数部分,不过,glsl的floor可以直接对向量使用。我们通过floor(st)实际上取到了0,0到9,9,一共10行*10列=100个方块。然后我们通过random函数给每一个方块随机一个颜色,最终实现的结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/c5/ac66f16a9d1fd8baf0ed86442462fbc5.jpeg" alt="">
|
||||
|
||||
此外,我们还可以结合随机和动态效果。具体的方法就是传入一个代表时间的uTime变量,实际代码和最终效果如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform float uTime;
|
||||
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * vec2(100.0, 50.0);
|
||||
|
||||
st.x -= (1.0 + 10.0 * random(vec2(floor(st.y)))) * uTime;
|
||||
|
||||
vec2 ipos = floor(st); // integer
|
||||
vec2 fpos = fract(st); // fraction
|
||||
|
||||
vec3 color = vec3(step(random(ipos), 0.7));
|
||||
color *= step(0.2,fpos.y);
|
||||
|
||||
gl_FragColor.rgb = color;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/9c/8cee3889c8034f0d94211fcc1ec72b9c.gif" alt="">
|
||||
|
||||
除此之外,我们用Shader来实现网格类的效果也特别方便。比如,下面我们就在Shader中用smoothstep函数生成可以随机旋转方向的线段,从而生成一个迷宫。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision mediump float;
|
||||
#endif
|
||||
|
||||
#define PI 3.14159265358979323846
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform vec2 u_resolution;
|
||||
uniform int rows;
|
||||
|
||||
float random (in vec2 _st) {
|
||||
return fract(sin(dot(_st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
vec2 truchetPattern(in vec2 _st, in float _index){
|
||||
_index = fract(((_index-0.5)*2.0));
|
||||
if (_index > 0.75) {
|
||||
_st = vec2(1.0) - _st;
|
||||
} else if (_index > 0.5) {
|
||||
_st = vec2(1.0-_st.x,_st.y);
|
||||
} else if (_index > 0.25) {
|
||||
_st = 1.0-vec2(1.0-_st.x,_st.y);
|
||||
}
|
||||
return _st;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * float(rows);
|
||||
vec2 ipos = floor(st); // integer
|
||||
vec2 fpos = fract(st); // fraction
|
||||
|
||||
vec2 tile = truchetPattern(fpos, random( ipos ));
|
||||
float color = 0.0;
|
||||
|
||||
color = smoothstep(tile.x-0.3,tile.x,tile.y)-
|
||||
smoothstep(tile.x,tile.x+0.3,tile.y);
|
||||
|
||||
gl_FragColor = vec4(vec3(color),1.0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/3b/a3ae93b53bb2e3992267a1c996a5583b.jpeg" alt="">
|
||||
|
||||
## 要点总结
|
||||
|
||||
今天,我们讲了可视化中三种常用图案的生成原理。
|
||||
|
||||
第一种,批量重复图案。一般来说,在绘制批量重复图案的时候,我们可以采用2种方案。首先是使用CSS的background-image属性,利用backgroud-repeat快速重复绘制。其次,我们可以使用片元着色器,利用GPU的并行渲染的特点来绘制。
|
||||
|
||||
第二种,分形图案。绘制分形图案有一个可以直接的公式,曼德勃罗特集。我们可以使用它来绘制分形图案。
|
||||
|
||||
第三种是在重复图案上增加随机性,我们可以在片元着色器中使用伪随机函数,来给重复图案实现随机效果。
|
||||
|
||||
虽然我们说几何图形是用来承载数据信息,图案是来强化视觉效果的,但实际上,它们也并没有绝对的界限,有时候我们也可以将图案与数据信息一起管理。比如说,在上面那个动态效果的例子中,我们可以调整动态参数,让图形的运动速度或者黑白块的分布和数据量或者信息内容联系起来。这会大大强化可视化的视觉效果,从而加深用户对信息的理解。
|
||||
|
||||
在这一节课,我们讲了大量关于WebGL的片元着色器的知识。这是因为,片元着色器是最适合生成和绘制这些图案的技术,但这也并不意味着用其他图形系统,比如SVG或者Canvas就没法很好地生成并绘制这些图案了。
|
||||
|
||||
实际上,它们的基本原理是相同的,所以用SVG或Canvas同样可以绘制这些图案。只不过,因为SVG和Canvas渲染不能深入控制GPU底层,所以就没法做到像WebGL这样并行高效地渲染这些图案。那如果在选择SVG和Canvas的可视化应用中,需要绘制大量的这些图案,就必然会导致性能瓶颈,这也是为什么我们一定要了解和掌握WebGL技术,只有这样,我们才能真正掌握绘制极有视觉冲击力的复杂图案的能力。
|
||||
|
||||
最后,我还要啰嗦几句,如果你对片元着色器应用还不是很熟悉,对上面的代码还有疑问或者不是很理解,那也没有关系,你可以花一点时间,仔细研究一下[GitHub 仓库](https://github.com/akira-cn/graphics/tree/master/repeat-and-random)的源代码。要记住,动手实践永远是我们最好的学习方式,没有之一。
|
||||
|
||||
另外,在接下来的课程里,我们还会大量使用片元着色器创建更多有趣、炫酷的视觉效果。所以,我也建议你去从头看看这份关于片元着色器的学习资料,[The Book of Shaders](https://thebookofshaders.com/?lan=ch),相信你会非常有收获。
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
<li>
|
||||
在前面的例子里,我们实现了一个10*10的灰色块方阵,这里我们使用的是灰度颜色,你能够渲染出彩色方块吗?你可以尝试将随机数映射成HSV坐标中的H,然后绘制出不同的彩色方阵。
|
||||
</li>
|
||||
<li>
|
||||
在实现抽奖程序的时候,我们在Shader中使用的是伪随机函数random。那如果要实现真正的随机数,我们该怎么做呢?如果我们希望实现的迷宫图案,在我们每次刷新网页的时候都不相同,这个功能你可以实现吗?你可以fork GitHub仓库的代码,然后把伪随机迷宫图案修改成真正随机迷宫图案,然后把你的代码和实际效果分享出来。
|
||||
</li>
|
||||
<li>
|
||||
我们知道,使用background-image的弊端是,当我们用坐标变换来缩放或移动图形的时候,作为元素背景的网格是不会随着缩放或移动而改变的。但使用Shader,我们就能够避免这个问题了。
|
||||
</li>
|
||||
|
||||
不过,我们在课程中没有给出缩放和移动图形的例子。你能够扩展我给出的例子,实现图案随着图形的缩放和移动变化的效果吗(这里,我再给你一个小提示,你可以使用顶点着色器和仿射变换矩阵来实现)?
|
||||
|
||||
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
|
||||
|
||||
## 源码
|
||||
|
||||
[课程示例代码](https://github.com/akira-cn/graphics/tree/master/repeat-and-random)
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
[1]基于WebGL底层简单封装的基础库 [gl-renderer]的官方文档([https://github.com/akira-cn/gl-renderer](https://github.com/akira-cn/gl-renderer)) ,它可以大大简化WebGL代码的书写难度<br>
|
||||
[2]很棒的学习片元着色器的教程 [The Book of Shaders](https://thebookofshaders.com/?lan=ch) .
|
||||
617
极客时间专栏/跟月影学可视化/视觉基础篇/12 | 如何使用滤镜函数实现美颜效果?.md
Normal file
617
极客时间专栏/跟月影学可视化/视觉基础篇/12 | 如何使用滤镜函数实现美颜效果?.md
Normal file
@@ -0,0 +1,617 @@
|
||||
<audio id="audio" title="12 | 如何使用滤镜函数实现美颜效果?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/ec/e8e94c2c522f7699f38fbfea65f385ec.mp3"></audio>
|
||||
|
||||
你好,我是月影。
|
||||
|
||||
通过前面的课程,我们初步了解了浏览器的图形系统,也学会了使用基本的数学和几何方法来生成和处理图像,还能用简单的图形组合来构成复杂的图案。从这一节课开始,我们进入一个新的模块,开始学习像素处理。
|
||||
|
||||
在可视化领域里,我们常常需要处理大规模的数据,比如,需要呈现数万甚至数十万条信息在空间中的分布情况。如果我们用几何绘制的方式将这些信息一一绘制出来,性能可能就会很差。
|
||||
|
||||
这时,我们就可以将这些数据简化为像素点进行处理。这种处理图像的新思路就叫做**像素化。**在可视化应用中,图片像素化处理是一个很重要手段,它能够在我们将原始数据信息转换成图形后,进一步处理图形的细节,突出我们想要表达的信息,还能让视觉呈现更有冲击力。
|
||||
|
||||
因为像素化的内容比较复杂,能做的事情也非常多,所以我们会用五节课的时间来讨论。今天是第一节课,我们先来看看图片像素化的基本思路和方法,体会如何用像素化来处理照片,从而达到“美颜”的效果。
|
||||
|
||||
## 如何理解像素化?
|
||||
|
||||
首先,我们来理解两个基础的概念。第一个是像素化。所谓像素化,就是把一个图像看成是由一组像素点组合而成的。每个像素点负责描述图像上的一个点,并且带有这个点的基本绘图信息。那对于一张800像素宽、600像素高的图片来说,整张图一共就有48万个像素点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/e5/a4ba75ac8c1a1f1d05711f50eeb6fae5.jpeg" alt="">
|
||||
|
||||
这么多的像素点是怎么存储的呢?Canvas2D以4个通道来存放每个像素点的颜色信息,每个通道是8个比特位,也就是0~255的十进制数值,4个通道对应RGBA颜色的四个值。后面,我们会用RGBA通道来分别代表它们。
|
||||
|
||||
知道了什么是像素化,那像素处理又是怎么一回事呢?像素处理实际上就是我们为了达到特定的视觉效果,用程序来处理图像上每个像素点。像素处理的应用非常广泛,能实现的效果也非常多。下面,我会列举几个常用的效果,希望你通过它们能理解像素处理的一般方法和思路。
|
||||
|
||||
### 应用一:实现灰度化图片
|
||||
|
||||
在可视化中,当我们要给用户强调某些信息的时候,一般会将我们不想强调的信息区域置成灰度状态。这样,用户就能够快速抓住我们想要表达的重点了。这个过程就是**灰度化图片**。简单来说就是将一张彩色图片变为灰白色图片。具体的实现思路是,我们先将该图片的每个像素点的R、G、B通道的值进行加权平均,然后将这个值作为每个像素点新的R、G、B通道值,具体公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/60/8e1d92356aa99ca858c9fd13336c1660.jpeg" alt="">
|
||||
|
||||
其中R、G、B是原图片中的R、G、B通道的色值,V是加权平均色值,a、b、c是加权系数,满足 (a + b + c) = 1。
|
||||
|
||||
好了,灰度化的原理你已经知道了,下面我们通过一个具体的例子来演示一下实际操作的过程。首先,我们写一段简单的JavaScript代码,通过这段代码把一张图片给加载并绘制到Canvas上,代码如下:
|
||||
|
||||
```
|
||||
<canvas id="paper" width="0" height="0"></canvas>
|
||||
<script>
|
||||
function loadImage(src) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
return new Promise((resolve) => {
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
const canvas = document.getElementById('paper');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
(async function () {
|
||||
// 异步加载图片
|
||||
const img = await loadImage('https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg');
|
||||
|
||||
const {width, height} = img;
|
||||
// 将图片绘制到 canvas
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.drawImage(img, 0, 0);
|
||||
}());
|
||||
</script>
|
||||
|
||||
```
|
||||
|
||||
这段代码开始创建一个image元素,然后通过image元素的src属性异步加载图片,加载完成后,通过canvas的2d上下文对象的drawImage方法,将图片元素绘制到canvas上。这张图片如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/36/d22c3ff49fd473b4cb6b52f036542a36.jpg" alt="">
|
||||
|
||||
接下来,我们要获取每个像素点的R、G、B值。具体的操作就是通过canvas的2d上下文,获取图片剪裁区的数据imgData。那什么是imgData呢?imgData是我们在像素处理中经常用到的对象,它是一个ImageData对象,它有3个属性,分别是width、height和data。其中width表示剪裁区的宽度属性,height表示剪裁区的高度属性,data用来存储图片的全部像素信息。
|
||||
|
||||
宽、高属性我就不用多说了,我来重点说说data是怎么保存图片全部像素信息的。首先,图片的全部像素信息会以**类型数组**(Uint8ClampedArray)的形式保存在ImageData对象的data属性里,而类型数组的每4个元素组成一个像素的信息,这四个元素依次表示该像素的RGBA四通道的值,所以它的数据结构如下:
|
||||
|
||||
```
|
||||
data[0] // 第1行第1列的红色通道值
|
||||
data[1] // 第1行第1列的绿色通道值
|
||||
data[2] // 第1行第1列的蓝色通道值
|
||||
data[3] // 第1行第1列的Alpha通道值
|
||||
data[4] // 第1行第2列的红色通道值
|
||||
data[5] // 第1行第2列的绿色通道值
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
结合这个结构,我们可以得出data属性的类型数组的总长度:**width * height * 4。<strong>这是因为图片一共是**width * height</strong>个像素点,每个像素点有4个通道,所以总长度是像素点的4倍。
|
||||
|
||||
知道了数组长度以后,接着,我们就可以遍历data数组,读取每个像素的RGBA四通道的值,将原本的RGBA值都用一个加权平均值**0.2126 r + 0.7152 g + 0.0722 * b**替代了,代码如下:
|
||||
|
||||
```
|
||||
for(let i = 0; i < width * height * 4; i += 4) {
|
||||
const r = data[i],
|
||||
g = data[i + 1],
|
||||
b = data[i + 2],
|
||||
a = data[i + 3];
|
||||
// 对RGB通道进行加权平均
|
||||
const v = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
data[i] = v;
|
||||
data[i + 1] = v;
|
||||
data[i + 2] = v;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里你可能会觉得奇怪,我们为什么用**0.2126、0.7152**和**0.0722**这三个权重,而不是都用算术平均值**1/3**呢?这是因为,人的视觉对R、G、B三色通道的敏感度是不一样的,对绿色敏感度高,所以加权值高,对蓝色敏感度低,所以加权值低。
|
||||
|
||||
最后,我们将处理好的数据写回到Canvas中去。这样,我们就得到了一张经过灰度化后的图片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/35/2a4d672f1dc87f951a4c7ea2e9871335.jpg" alt="" title="灰度化后的图片">
|
||||
|
||||
灰度化图片的过程非常简单,我们一起来总结一下。首先,我们加载一张图片将它绘制到canvas,接着我们通过getImageData获取imageData信息,再通过imageData.data遍历图像上的所有像素点,对每个像素点的RGBA值进行加权平均处理,然后将处理好的信息回写到canvas中去。
|
||||
|
||||
我把灰度化图片的过程总结了一张流程图,你也可以参考它来理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/68/81a8ef241e0d0f0924540b57a7d52f68.jpg" alt="">
|
||||
|
||||
### 重构代码以扩展其他效果
|
||||
|
||||
实际上,灰度化只是像素颜色处理中一个最简单的应用,除此以外,我们还可以对像素颜色做其他变换,比如增强或减弱某个通道的色值,改变颜色的亮度、对比度、饱和度、色相等等。
|
||||
|
||||
那为了方便讲解,也为了更好地复用代码实现其他的功能,我先重构一下上面的代码,将我们最关注的**循环处理每个像素的颜色信息**这一步单独剥离出来,再把其他步骤都**分解并抽象成通用的模块**以便于实现其他效果的时候引入。
|
||||
|
||||
重构代码的实现思路是,先创建一个**lib/utils.js**文件,然后把加载图片的函数**loadImage**,获取imageData对象的函数**getImageData**,以及遍历imageData中的类型数组的函数**traverse**,都添加到**lib/utils.js**文件中。代码如下:
|
||||
|
||||
```
|
||||
// lib/utls.js
|
||||
|
||||
// 异步加载图片
|
||||
export function loadImage(src) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
return new Promise((resolve) => {
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
const imageDataContext = new WeakMap();
|
||||
// 获得图片的 imageData 数据
|
||||
export function getImageData(img, rect = [0, 0, img.width, img.height]) {
|
||||
let context;
|
||||
if(imageDataContext.has(img)) context = imageDataContext.get(img);
|
||||
else {
|
||||
const canvas = new OffscreenCanvas(img.width, img.height);
|
||||
context = canvas.getContext('2d');
|
||||
context.drawImage(img, 0, 0);
|
||||
imageDataContext.set(img, context);
|
||||
}
|
||||
return context.getImageData(...rect);
|
||||
}
|
||||
|
||||
// 循环遍历 imageData 数据
|
||||
export function traverse(imageData, pass) {
|
||||
const {width, height, data} = imageData;
|
||||
for(let i = 0; i < width * height * 4; i += 4) {
|
||||
const [r, g, b, a] = pass({
|
||||
r: data[i] / 255,
|
||||
g: data[i + 1] / 255,
|
||||
b: data[i + 2] / 255,
|
||||
a: data[i + 3] / 255,
|
||||
index: i,
|
||||
width,
|
||||
height,
|
||||
x: ((i / 4) % width) / width,
|
||||
y: Math.floor(i / 4 / width) / height});
|
||||
data.set([r, g, b, a].map(v => Math.round(v * 255)), i);
|
||||
}
|
||||
return imageData;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们这样做了之后,像素处理的应用代码就可以得到简化。简化后的代码如下:
|
||||
|
||||
```
|
||||
import {loadImage, getImageData, traverse} from './lib/util.js';
|
||||
|
||||
const canvas = document.getElementById('paper');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
(async function () {
|
||||
// 异步加载图片
|
||||
const img = await loadImage('assets/girl1.jpg');
|
||||
// 获取图片的 imageData 数据对象
|
||||
const imageData = getImageData(img);
|
||||
// 遍历 imageData 数据对象
|
||||
traverse(imageData, ({r, g, b, a}) => { // 对每个像素进行灰度化处理
|
||||
const v = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return [v, v, v, a];
|
||||
});
|
||||
// 更新canvas内容
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}());
|
||||
|
||||
```
|
||||
|
||||
这样做的好处是,traverse函数会自动遍历图片的每个像素点,把获得的像素信息传给参数中的回调函数处理。这样,我们就只关注 traverse 函数里面的处理过程就可以了。
|
||||
|
||||
### 应用二:使用像素矩阵通用地改变像素颜色
|
||||
|
||||
在灰度化图片的例子中,我们用加权平均的计算公式来替换图片的RGBA的值。这本质上其实是利用线性方程组改变了图片中每一个像素的RGB通道的原色值,将每个通道的色值映射为一个新色值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/eb/8325c3e173c27652172feed71991dfeb.jpeg" alt="">
|
||||
|
||||
除了加权平均的计算公式以外,我们还可以用其他的线性方程组来实现各种不同的像素变换效果。比如说,要想实现改变图片的亮度,我们可以将R、G、B通道的值都乘以一个常量p,公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/7f/8b692dd0dafa34bdeab5f136a777457f.jpeg" alt="">
|
||||
|
||||
这里的p是一个常量,如果它小于1,那么R、G、B值就会变小,图片就会变暗,也就更接近于黑色了。相反,如果p大于1,图片就会变亮,更接近白色。这样一来,我们用不同的公式就可以将像素的颜色处理成我们所期望的结果了。
|
||||
|
||||
但如果你想要实现不同的颜色变换,就必须要使用不同的方程组,这会让我们使用起来非常麻烦。那你肯定想问了,有没有一种方式,可以更通用地实现更多的颜色变换效果呢?当然是有的,我们可以引入一个颜色矩阵,它能够处理几乎所有的颜色变换类滤镜。
|
||||
|
||||
我们创建一个4*5颜色矩阵,让它的第一行决定红色通道,第二行决定绿色通道,第三行决定蓝色通道,第四行决定Alpha通道。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/88/a1d08c1750cd074c4a064635f30e6188.jpeg" alt="">
|
||||
|
||||
那如果要改变一个像素的颜色效果,我们只需要将该矩阵与像素的颜色向量相乘就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/5f/54849d44dc3036e8fa7d7b92b8c0885f.jpeg" alt="">
|
||||
|
||||
这样一来,灰度化图片的处理过程,就可以描述成如下的颜色矩阵:
|
||||
|
||||
```
|
||||
function grayscale(p = 1) {
|
||||
const r = 0.2126 * p;
|
||||
const g = 0.7152 * p;
|
||||
const b = 0.0722 * p;
|
||||
|
||||
|
||||
return [
|
||||
r + 1 - p, g, b, 0, 0,
|
||||
r, g + 1 - p, b, 0, 0,
|
||||
r, g, b + 1 - p, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
注意,这里我们引入了一个参数p,它是一个0~1的值,表示灰度化的程度,1是完全灰度化,0是完全不灰度,也就是保持原始色彩。这样一来,我们通过调节p的值就可以改变图片灰度化的程度。因此这个灰度化矩阵,比前面直接用灰度化公式更加通用。
|
||||
|
||||
因为p的取值范围是0~1,所以p的取值可以分成三种情况。下面,我们一起来分析一下。
|
||||
|
||||
第一种,p等于0,这个时候,r、g、b的值也都是0,所以返回的矩阵就退化成单位矩阵,代码如下。这样一来,新色值和原色值就完全相同了。
|
||||
|
||||
```
|
||||
[
|
||||
1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
0, 0, 1, 0, 0,
|
||||
0, 0, 0, 1, 0
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
第二种情况当p等于1的时候,这个矩阵就正好对应我们前面的灰度化公式。
|
||||
|
||||
```
|
||||
[
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
第三种取值情况,当p处于0~1之间的时候,颜色矩阵的值就在完全灰度的矩阵和单位矩阵之间线性变化。这样我们就实现了可调节的灰度化颜色矩阵。
|
||||
|
||||
但是,光有颜色矩阵还不行,要想实现不同的颜色变化,根据前面的公式,我们还得让旧的色值与颜色矩阵相乘,把新的色值计算出来。
|
||||
|
||||
为了方便处理,我们可以增加处理颜色矩阵的模块。让它包含两个函数,一个是处理颜色矩阵的矩阵乘法运算**multiply**函数,另一个是将RGBA颜色通道组成的向量与颜色矩阵相乘,得到新色值的**transformColor**函数。
|
||||
|
||||
```
|
||||
// lib/color-matrix.js
|
||||
// 将 color 通过颜色矩阵映射成新的色值返回
|
||||
export function transformColor(color, ...matrix) {
|
||||
// 颜色向量与矩阵相乘
|
||||
... 省略的代码
|
||||
}
|
||||
// 将颜色矩阵相乘
|
||||
export function multiply(a, b) {
|
||||
// 颜色矩阵相乘
|
||||
...省略的代码
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那你可能想问,为什么我们这里不仅提供了处理色值映射的**transformColor**,还提供了一个矩阵乘法的**multiply**方法呢?
|
||||
|
||||
这是因为根据矩阵运算的性质,我们可以将多次颜色变换的过程,简化为将相应的颜色矩阵相乘,然后用最终的那个矩阵对颜色值进行映射。具体的过程你可以看看我给出的流程图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/02/cd38c0987e56d1d93175c623f641d502.jpg" alt="">
|
||||
|
||||
这样,灰度化图片的实现部分就可以写成如下代码:
|
||||
|
||||
```
|
||||
...省略代码...
|
||||
|
||||
traverse(imageData, ({r, g, b, a}) => {
|
||||
return transformColor([r, g, b, a], grayscale(1));
|
||||
});
|
||||
|
||||
...省略代码...
|
||||
|
||||
|
||||
```
|
||||
|
||||
这里的**grayscale函数**返回了实现灰度化的颜色矩阵,而要实现其他颜色变换效果,我们可以定义其他函数返回其他的颜色矩阵。这种返回颜色矩阵的函数,我们一般称为**颜色滤镜函数**。
|
||||
|
||||
抽象出了颜色滤镜函数之后,我们处理颜色代码的过程可以规范成如下图所示的过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/57/f9/57cc41d94e5ca657a906571d99e5c0f9.jpg" alt="">
|
||||
|
||||
我们还可以增加其他的颜色滤镜函数,比如:
|
||||
|
||||
```
|
||||
function channel({r = 1, g = 1, b = 1}) {
|
||||
return [
|
||||
r, 0, 0, 0, 0,
|
||||
0, g, 0, 0, 0,
|
||||
0, 0, b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个**channel**滤镜函数可以过滤或增强某个颜色通道。
|
||||
|
||||
```
|
||||
// 增强红色通道,减弱绿色通道
|
||||
traverse(imageData, ({r, g, b, a}) => {
|
||||
return transformColor([r, g, b, a], channel({r: 1.5, g: 0.75}));
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
举个例子,当我们调用channel({r: 1.5, g: 0.75})的时候,红色通道的值被映射为原来的1.5倍,绿色通道的值则被映射为0.75倍。这样得到的图片就显得比原来要红。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/ed/bfd11b7f7d3f59b9a3b8d5bb2fd1faed.jpg" alt="">
|
||||
|
||||
在处理图片时,我们还会用到有一些常用的颜色滤镜,比如,可以修改图片的亮度(Brightness)、饱和度(Saturate)、对比度(Constrast)、透明度(Opacity),还有对图片反色(Invert)和旋转色相(HueRotate)。这些滤镜函数的结构都是一样的,只是返回的矩阵不同而已。
|
||||
|
||||
它们的滤镜函数如下,你结合上节课我们学过的颜色理论很容易就可以理解了,我就不详细讲了。
|
||||
|
||||
```
|
||||
// 改变亮度,p = 0 全暗,p > 0 且 p < 1 调暗,p = 1 原色, p > 1 调亮
|
||||
function brightness(p) {
|
||||
return [
|
||||
p, 0, 0, 0, 0,
|
||||
0, p, 0, 0, 0,
|
||||
0, 0, p, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 饱和度,与grayscale正好相反
|
||||
// p = 0 完全灰度化,p = 1 原色,p > 1 增强饱和度
|
||||
function saturate(p) {
|
||||
const r = 0.2126 * (1 - p);
|
||||
const g = 0.7152 * (1 - p);
|
||||
const b = 0.0722 * (1 - p);
|
||||
return [
|
||||
r + p, g, b, 0, 0,
|
||||
r, g + p, b, 0, 0,
|
||||
r, g, b + p, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 对比度, p = 1 原色, p < 1 减弱对比度,p > 1 增强对比度
|
||||
function contrast(p) {
|
||||
const d = 0.5 * (1 - p);
|
||||
return [
|
||||
p, 0, 0, 0, d,
|
||||
0, p, 0, 0, d,
|
||||
0, 0, p, 0, d,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 透明度,p = 0 全透明,p = 1 原色
|
||||
function opacity(p) {
|
||||
return [
|
||||
1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
0, 0, 1, 0, 0,
|
||||
0, 0, 0, p, 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 反色, p = 0 原色, p = 1 完全反色
|
||||
function invert(p) {
|
||||
const d = 1 - 2 * p;
|
||||
return [
|
||||
d, 0, 0, 0, p,
|
||||
0, d, 0, 0, p,
|
||||
0, 0, d, 0, p,
|
||||
0, 0, 0, 1, 0,
|
||||
]
|
||||
}
|
||||
|
||||
// 色相旋转,将色调沿极坐标转过deg角度
|
||||
function hueRotate(deg) {
|
||||
const rotation = deg / 180 * Math.PI;
|
||||
const cos = Math.cos(rotation),
|
||||
sin = Math.sin(rotation),
|
||||
lumR = 0.2126,
|
||||
lumG = 0.7152,
|
||||
lumB = 0.0722;
|
||||
return [
|
||||
lumR + cos * (1 - lumR) + sin * (-lumR), lumG + cos * (-lumG) + sin * (-lumG), lumB + cos * (-lumB) + sin * (1 - lumB), 0, 0,
|
||||
lumR + cos * (-lumR) + sin * (0.143), lumG + cos * (1 - lumG) + sin * (0.140), lumB + cos * (-lumB) + sin * (-0.283), 0, 0,
|
||||
lumR + cos * (-lumR) + sin * (-(1 - lumR)), lumG + cos * (-lumG) + sin * (lumG), lumB + cos * (1 - lumB) + sin * (lumB), 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然了,在实际工作中为了实现更多样的效果,我们经常需要叠加使用多种滤镜函数。那这些滤镜函数该如何叠加呢?根据我们前面说过的矩阵乘法的特性,其实只要将这些滤镜函数返回的滤镜矩阵先相乘,然后把得到的矩阵再与输入的RGBA颜色向量相乘就可以了。
|
||||
|
||||
我们之前封装的**transformColor**函数,就完全考虑到了应用多个滤镜函数的需求,它的参数可以接受多个矩阵,如果传给它多个矩阵,它会将每个矩阵一一进行乘法运算。
|
||||
|
||||
```
|
||||
// 将 color 通过颜色矩阵映射成新的色值返回
|
||||
export function transformColor(color, ...matrix) {
|
||||
...
|
||||
matrix = matrix.reduce((m1, m2) => multiply(m1, m2));
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以,如果在一些需要暖色调的场景中,我们想让一张图片变得有“阳光感”,那我们可以使用叠加channel函数中的红色通道、brightness函数和 saturate函数来实现这一效果。
|
||||
|
||||
具体的实现代码和效果如下所示。
|
||||
|
||||
```
|
||||
traverse(imageData, ({r, g, b, a}) => {
|
||||
return transformColor(
|
||||
[r, g, b, a],
|
||||
channel({r: 1.2}), // 增强红色通道
|
||||
brightness(1.2), // 增强亮度
|
||||
saturate(1.2), // 增强饱和度
|
||||
);
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/28/e296b8a0069ffb3030865525e5356f28.jpg" alt="">
|
||||
|
||||
### 应用三:使用高斯模糊对照片美颜
|
||||
|
||||
你肯定发现了,刚才我们讲的颜色滤镜都比较简单。没错,其实它们都是一些简单滤镜。那在实际的可视化项目中,我们通常会使用颜色滤镜来增强视觉呈现的细节,而用一种相对复杂的滤镜来模糊背景,从而突出我们要呈现给用户的内容。这个复杂滤镜就叫做高斯模糊(Gaussian Blur)。
|
||||
|
||||
高斯模糊的原理与颜色滤镜不同,高斯模糊不是单纯根据颜色矩阵计算当前像素点的颜色值,而是会按照高斯分布的权重,对当前像素点及其周围像素点的颜色按照高斯分布的权重加权平均。这样做,我们就能让图片各像素色值与周围色值的差异减小,从而达到平滑,或者说是模糊的效果。所以,高斯模糊是一个非常重要的**平滑效果滤镜**(Blur Filters)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/3d/1710fef0c46687byy6e9b7f816e48c3d.jpeg" alt="">
|
||||
|
||||
高斯模糊的算法分两步,第一步是生成高斯分布矩阵,这个矩阵的作用是按照高斯函数提供平滑过程中参与计算的像素点的加权平均权重。代码如下:
|
||||
|
||||
```
|
||||
function gaussianMatrix(radius, sigma = radius / 3) {
|
||||
const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
|
||||
const b = -1 / (2 * sigma ** 2);
|
||||
let sum = 0;
|
||||
const matrix = [];
|
||||
for(let x = -radius; x <= radius; x++) {
|
||||
const g = a * Math.exp(b * x ** 2);
|
||||
matrix.push(g);
|
||||
sum += g;
|
||||
}
|
||||
|
||||
for(let i = 0, len = matrix.length; i < len; i++) {
|
||||
matrix[i] /= sum;
|
||||
}
|
||||
return {matrix, sum};
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
那高斯分布的原理是什么呢?它其实就是正态分布,简单来说就是将当前像素点的颜色值设置为附近像素点颜色值的加权平均,而距离当前像素越近的点的权重越高,权重分布满足正态分布。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/7c/5af394600f578789yy9b73768f18fc7c.jpeg" alt="">
|
||||
|
||||
因为高斯分布涉及比较专业的数学知识,所以要展开细讲会非常复杂,而且我们在实际工作中只要理解原理并且会使用相关公式就够了。因此,你要记住我这里给出的二维高斯函数公式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/f5/4134360e2c0105594491b9702eb036f5.jpeg" alt="" title="二维高斯函数">
|
||||
|
||||
这个公式其实就是上面代码中的计算式:
|
||||
|
||||
```
|
||||
const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
|
||||
const b = -1 / (2 * sigma ** 2);
|
||||
|
||||
const g = a * Math.exp(b * x ** 2);
|
||||
|
||||
```
|
||||
|
||||
**第二步,对图片在x轴、y轴两个方向上分别进行高斯运算**。也就是沿着图片的宽、高方向对当前像素和它附近的像素,应用上面得出的权重矩阵中的值进行加权平均。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/16/77f99e50ffa97e3359bdea697a92f616.jpeg" alt="">
|
||||
|
||||
实现代码如下:
|
||||
|
||||
```
|
||||
export function gaussianBlur(pixels, width, height, radius = 3, sigma = radius / 3) {
|
||||
const {matrix, sum} = gaussianMatrix(radius, sigma);
|
||||
// x 方向一维高斯运算
|
||||
for(let y = 0; y < height; y++) {
|
||||
for(let x = 0; x < width; x++) {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
for(let j = -radius; j <= radius; j++) {
|
||||
const k = x + j;
|
||||
if(k >= 0 && k < width) {
|
||||
const i = (y * width + k) * 4;
|
||||
r += pixels[i] * matrix[j + radius];
|
||||
g += pixels[i + 1] * matrix[j + radius];
|
||||
b += pixels[i + 2] * matrix[j + radius];
|
||||
}
|
||||
}
|
||||
const i = (y * width + x) * 4;
|
||||
// 除以 sum 是为了消除处于边缘的像素, 高斯运算不足的问题
|
||||
pixels[i] = r / sum;
|
||||
pixels[i + 1] = g / sum;
|
||||
pixels[i + 2] = b / sum;
|
||||
}
|
||||
}
|
||||
|
||||
// y 方向一维高斯运算
|
||||
...省略代码
|
||||
|
||||
return pixels;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在实现了高斯模糊函数之后,我们就可以对前面例子中的图片进行高斯模糊处理了,代码如下:
|
||||
|
||||
```
|
||||
...省略代码...
|
||||
|
||||
// 获取图片的 imageData 数据对象
|
||||
const imageData = getImageData(img);
|
||||
// 对imageData应用高斯模糊
|
||||
gaussianBlur(data, width, height);
|
||||
|
||||
...省略代码...
|
||||
|
||||
```
|
||||
|
||||
由于高斯模糊不是处理单一像素,而是处理一个范围内的所有像素,因此我们不能采用前面的 traverse 遍历函数的方法,而是整体对图片所有像素应用高斯模糊函数。我们最终得到的效果如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/27/40f674c8b282712490a3a44c1926cc27.jpg" alt="">
|
||||
|
||||
你会发现,图片整体都变得“模糊”了。那这种模糊效果就完全可以用来给图片“磨皮”。处理后的图片对比效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/f5/8fa0dbf5e0514193c0b8b15e5ea294f5.jpg" alt="">
|
||||
|
||||
在这里,我们不仅给图片加了高斯模糊,还用灰度化、增强了饱和度、增强了对比度和亮度。这样图片上的人的皮肤就会显得更白皙。我建议你,可以试着自己动手来实现看看。
|
||||
|
||||
## 像素化与CSS滤镜
|
||||
|
||||
那在上面这几个例子中,我们都是自己通过Canvas的getImageData方法拿到像素数据,然后遍历读取或修改像素信息。实际上,如果只是按照某些特定规则改变一个图像上的所有像素,浏览器提供了更简便的方法:CSS**滤镜**。
|
||||
|
||||
前面讲过的几种图片效果,我们都可以通过CSS滤镜实现。比如灰度化图片可以直接使用img元素来,代码如下:
|
||||
|
||||
```
|
||||
<img src="https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg" style="filter:grayscale(100%)">
|
||||
|
||||
|
||||
```
|
||||
|
||||
同样美颜效果我们也可以用CSS滤镜来实现:
|
||||
|
||||
```
|
||||
<img src="https://p0.ssl.qhimg.com/t01161037b5fe87f236.jpg"
|
||||
style="filter:blur(1.5px) grayscale(0.5) saturate(1.2) contrast(1.1) brightness(1.2)">
|
||||
|
||||
|
||||
```
|
||||
|
||||
除此以外,比较新的浏览器上还实现了原生的Canvas滤镜,与CSS滤镜相对应。[CSS滤镜](https://developer.mozilla.org/zh-CN/docs/Web/CSS/filter)和[Canvas滤镜](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/filter)都能实现非常丰富的滤镜效果,在处理视觉呈现上很有用。尽管CSS滤镜和Canvas滤镜都很好用,但是在实现效果上都有局限性,它们一般只能实现比较固定的视觉效果。这对于可视化来说,这并不够用。
|
||||
|
||||
这个时候像素处理的优势就体现出来了,用像素处理图片更灵活,因为它可以实现滤镜功能,还可以实现更加丰富的效果,包括一些非常炫酷的视觉效果,这正是可视化领域所需要的。
|
||||
|
||||
## 要点总结
|
||||
|
||||
这一节课我们学习了图片像素处理的基本原理。你要掌握的核心概念有滤镜函数、高斯模糊滤镜以及内置滤镜。
|
||||
|
||||
在像素处理的过程中,我们可以利用Canvas的getImageData API来获取图片的像素数据,然后遍历图片的每个像素点,最后用线性方程或者矩阵变换来改变图片的像素颜色。我们可以定义函数来生成矩阵变换,这些生成矩阵变换的函数就是滤镜函数。
|
||||
|
||||
像grayscale一类的函数,就是比较简单的滤镜函数了。除此以外,我们还学习了一类复杂滤镜,其中最基础的一种是高斯模糊滤镜,我们可以用它的平滑效果来给照片“美颜”。实际上,高斯模糊滤镜经常会用来做背景模糊,以突出主题内容。
|
||||
|
||||
而且,我们还探讨了像素化的优势,虽然浏览器提供了内置的Canvas滤镜和CSS滤镜可以实现大部分滤镜函数的功能,我们可以直接使用它们。但是,内置滤镜实现不了一些更加复杂的视觉效果,而像素化可以。不过,我也会在后面的课程中,继续和你深入讨论一些复杂的滤镜,用它们来生成复杂效果。
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
## 你能利用鼠标事件和今天学过的内容,做出一个图片局部“放大器”的效果吗?具体的效果就是,在鼠标移动在图片上时,将图片以鼠标坐标为圆心,指定半径内的内容局部放大。
|
||||
|
||||
## 源码
|
||||
|
||||
[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/pixels)
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
[《高斯模糊的算法》](http://www.ruanyifeng.com/blog/2012/11/gaussian_blur.html)
|
||||
|
||||
欢迎留言和我分享你的练习过程,如果有收获,欢迎你把这节课分享给你的朋友。
|
||||
412
极客时间专栏/跟月影学可视化/视觉基础篇/13 | 如何给简单的图案添加纹理和复杂滤镜?.md
Normal file
412
极客时间专栏/跟月影学可视化/视觉基础篇/13 | 如何给简单的图案添加纹理和复杂滤镜?.md
Normal file
@@ -0,0 +1,412 @@
|
||||
<audio id="audio" title="13 | 如何给简单的图案添加纹理和复杂滤镜?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/a7/7ea0b6c1c2c2624016125fd6522f9ea7.mp3"></audio>
|
||||
|
||||
你好,我是月影。
|
||||
|
||||
上一课我们讲了两类处理像素的滤镜,分别是颜色滤镜和高斯滤镜。其中,**颜色滤镜是基本的简单滤镜**。因为简单滤镜里的每个像素都是独立的,所以它的处理结果**不依赖于其他像素点的信息**,因此应用起来也比较简单。**而高斯滤镜也就是平滑效果滤镜**,它是最基本的**复杂滤镜**。复杂滤镜的处理结果不仅与当前像素有关,还与其周围的像素点有关,所以应用起来很复杂。
|
||||
|
||||
当然了,颜色滤镜和高斯滤镜能够实现的视觉效果有限。如果想要实现更复杂的视觉效果,我们还需要使用更多其他的滤镜。所以这一节课,我们就来说说,怎么结合不同滤镜实现更复杂的视觉效果。
|
||||
|
||||
## 其他简单滤镜在Canvas中的应用
|
||||
|
||||
我们知道,简单滤镜的处理效果和像素点的颜色有关。其实,还有一些简单滤镜的处理效果和像素点的坐标、外部环境(比如鼠标位置、时间)有关。这些滤镜虽然也是简单滤镜,但能实现的效果可不简单。让我们来看几个有趣的例子。
|
||||
|
||||
**第一个例子,实现图片边缘模糊的效果。**
|
||||
|
||||
```
|
||||
import {loadImage, getImageData, traverse} from './lib/util.js';
|
||||
const canvas = document.getElementById('paper');
|
||||
const context = canvas.getContext('2d');
|
||||
(async function () {
|
||||
const img = await loadImage('assets/girl1.jpg');
|
||||
const imageData = getImageData(img);
|
||||
traverse(imageData, ({r, g, b, a, x, y}) => {
|
||||
const d = Math.hypot((x - 0.5), (y - 0.5));
|
||||
a *= 1.0 - 2 * d;
|
||||
return [r, g, b, a];
|
||||
});
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}());
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,我们可以在遍历像素点的时候计算当前像素点到图片中心点的距离,然后根据距离设置透明度,这样我们就可以实现下面这样的边缘模糊效果了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/c0/33450156f67efc94d33e6f93ea8b93c0.jpg" alt="" title="边缘模糊效果示意图">
|
||||
|
||||
**第二个,我们可以利用像素处理实现图片融合**。比如说,我们可以给一张照片加上阳光照耀的效果。具体操作就是,把下面这张透明的PNG图片叠加到一张照片上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/1e/fe66ffff1ff6bd738bbb3ca4e037671e.jpg" alt="" title="纹理">
|
||||
|
||||
这种能叠加到其他照片上的图片,通常被称为**纹理**(Texture),叠加后的效果也叫做纹理效果。纹理与图片叠加的代码和效果如下:
|
||||
|
||||
```
|
||||
import {loadImage, getImageData, traverse, getPixel} from './lib/util.js';
|
||||
import {transformColor, brightness, saturate} from './lib/color-matrix.js';
|
||||
const canvas = document.getElementById('paper');
|
||||
const context = canvas.getContext('2d');
|
||||
(async function () {
|
||||
const img = await loadImage('assets/girl1.jpg');
|
||||
const sunlight = await loadImage('assets/sunlight.png');
|
||||
const imageData = getImageData(img);
|
||||
const texture = getImageData(sunlight);
|
||||
traverse(imageData, ({r, g, b, a, index}) => {
|
||||
const texColor = getPixel(texture, index);
|
||||
return transformColor([r, g, b, a], brightness(1 + 0.7 * texColor[3]), saturate(2 - texColor[3]));
|
||||
});
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}());
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/71/5cbf83d6217e5a1062e4c8380af85271.jpg" alt="" title="阳光照耀效果图">
|
||||
|
||||
另外,我们还可以选择不同的图片,来实现不同的纹理叠加效果,比如爆炸效果、水波效果等等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/7f/2c74538c71c23577a8d87b572335c57f.jpg" alt="" title="爆炸效果图">
|
||||
|
||||
纹理叠加能实现的效果非常多,所以它也是像素处理中的基础操作。不过,不管我们是用Canvas的ImageData API处理像素、应用滤镜还是纹理合成都有一个弊端,那就是我们必须循环遍历图片上的每个像素点。如果这个图片很大,比如它是2000px宽、2000px高,我们就需要遍历400万像素!这个计算量是相当大的。
|
||||
|
||||
因为在前面的例子中,我们生成的都只是静态的图片效果,所以这个计算量的问题还不明显。一旦我们想要利用像素处理,制作出更酷炫的动态效果,这样的计算量注定会成为性能瓶颈。这该怎么办呢?
|
||||
|
||||
好在,我们还有WebGL这个神器。WebGL通过运行着色器代码来完成图形的绘制和输出。其中,片元着色器负责处理像素点的颜色。那接下来,我们来说说如何用片元着色器处理像素。
|
||||
|
||||
## 片元着色器是怎么处理像素的?
|
||||
|
||||
如果想要在片元着色器中处理像素,我们需要先将图片的数据信息读取出来,交给WebGL程序来处理,这样我们就可以在着色器中处理了。
|
||||
|
||||
那么如何将图片数据信息读取出来呢?在WebGL中,我们会使用特殊的一种对象,叫做**纹理对象**(Texture)。我们将纹理对象作为一种特殊格式的变量,通过uniform传递给着色器,这样就可以在着色器中处理了。
|
||||
|
||||
纹理对象包括了整张图片的所有像素点的颜色信息,在着色器中,我们可以通过纹理坐标来读取对应的具体坐标处像素的颜色信息。纹理坐标是一个变量,类型是二维向量,x、y的值从0到1。在我们前面的课程里已经见过这个变量,就是我们传给顶点着色器的uv属性,对应片元着色器中的vUv变量。
|
||||
|
||||
因此,着色器中是可以加载纹理对象的。具体来说就是,我们先通过图片或者Canvas对象来创建纹理对象,然后通过uniform变量把它传入着色器。这样,我们再通过纹理坐标vUv就可以从加载的纹理对象上获取颜色信息。
|
||||
|
||||
### 1. 加载纹理
|
||||
|
||||
下面,我就详细说说每一步的具体操作。
|
||||
|
||||
**首先是创建纹理对象**。这个步骤比较复杂,因为设置不同的参数可以改变我们在Shader中对纹理取色的行为,所以其中最复杂的是参数部分。但在这里我们不需要知道太多,你先记住我在代码里给出的这几个就够了。其他的,如果之后需要用到,你再去参考[相关的资料](https://zhuanlan.zhihu.com/p/68894334)就可以了。代码如下所示:
|
||||
|
||||
```
|
||||
function createTexture(gl, img) {
|
||||
// 创建纹理对象
|
||||
const texture = gl.createTexture();
|
||||
|
||||
// 设置预处理函数,由于图片坐标系和WebGL坐标的Y轴是反的,这个设置可以将图片Y坐标翻转一下
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
|
||||
// 激活指定纹理单元,WebGL有多个纹理单元,因此在Shader中可以使用多个纹理
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
|
||||
// 将纹理绑定到当前上下文
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
// 指定纹理图像
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
|
||||
// 设置纹理的一些参数
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
}
|
||||
// 解除纹理绑定
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
纹理创建完成之后,我们还要**设置纹理**。具体来说就是,通过gl.activeTexture将对象绑定到纹理单元,再把纹理单元编号通过uniform写入shader变量中。
|
||||
|
||||
```
|
||||
function setTexture(gl, idx) {
|
||||
// 激活纹理单元
|
||||
gl.activeTexture(gl.TEXTURE0 + idx);
|
||||
// 绑定纹理
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
// 获取shader中纹理变量
|
||||
const loc = gl.getUniformLocation(program, 'tMap');
|
||||
// 将对应的纹理单元写入shader变量
|
||||
gl.uniform1i(loc, idx);
|
||||
// 解除纹理绑定
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样设置完成之后,我们就可以在Shader中使用纹理对象了。使用的代码如下:
|
||||
|
||||
```
|
||||
uniform sampler2D tMap;
|
||||
|
||||
...
|
||||
|
||||
vec3 color = texture2D(tMap, vUv); // 从纹理中提取颜色,vUv是纹理坐标
|
||||
|
||||
```
|
||||
|
||||
总的来说,在WebGL中,从创建纹理、设置纹理到使用纹理的步骤非常多,使用上可以说是非常繁琐了。方便起见,这里我们可以直接使用上一节课用过的gl-renderer库。经过gl-renderer库的封装之后,我们通过renderer.loadTexture就可以创建并加载纹理,然后直接将纹理对象本身作为renderer的uniforms属性值即可,就不用去关注其他细节了。具体的操作代码如下:
|
||||
|
||||
```
|
||||
const texture = await renderer.loadTexture(imgURL);
|
||||
|
||||
renderer.uniforms.tMap = texture;
|
||||
|
||||
```
|
||||
|
||||
知道了原理,接下来,我们就一起来动手把图片创建为纹理,然后加载到Shader中去使用吧。
|
||||
|
||||
首先,我们读取图片纹理并加载,代码如下所示。
|
||||
|
||||
```
|
||||
const texture = await renderer.loadTexture('https://p1.ssl.qhimg.com/t01cca5849c98837396.jpg');
|
||||
renderer.uniforms.tMap = texture;
|
||||
|
||||
renderer.setMeshData([{
|
||||
positions: [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
],
|
||||
attributes: {
|
||||
uv: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
],
|
||||
},
|
||||
cells: [[0, 1, 2], [2, 0, 3]],
|
||||
}]);
|
||||
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
然后,我们直接对纹理对象取色。对应的片元着色器代码如下所示:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
uniform sampler2D tMap;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = texture2D(tMap, vUv);
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
在片元着色器中,我们使用texture2D函数来获取纹理的颜色。这个函数支持两个参数,一个是纹理单元的uniform变量,另一个是要获取像素的坐标,这个坐标就是我们之前用过的uv纹理坐标。在这个片元着色器代码里,我们只是根据vUv坐标将纹理图片上对应的颜色取出来,其他什么也没做,所以画布上最终呈现出来的还是原始图片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/36/d22c3ff49fd473b4cb6b52f036542a36.jpg" alt="">
|
||||
|
||||
### 2. 实现滤镜
|
||||
|
||||
加载完纹理之后,我们就可以在它的基础上实现滤镜了。用Shader实现滤镜的方法也很简单,为了方便你理解,这次我们就只实现图片灰度化。我们可以在前面加载纹理的基础上,引入颜色矩阵,修改后的片元着色器代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform mat4 colorMatrix;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
gl_FragColor = colorMatrix * vec4(color.rgb, 1.0);
|
||||
gl_FragColor.a = color.a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,你可以把这段代码和我们刚才加载纹理的代码做个比较。你会发现,刚才我们只是简单地把color从纹理坐标中取出,直接把它设置给gl_FragColor。而现在,我们在设置gl_FragColor的时候,是先把颜色和colorMatrix相乘。这样其实就相当于是对颜色向量做了一个仿射变换。
|
||||
|
||||
对应地,我们修改一下前面的JavaScript代码。其中最主要的修改操作,就是通过uniform引入了一个colorMatrix。修改后的代码如下:
|
||||
|
||||
```
|
||||
const texture = await renderer.loadTexture('https://p1.ssl.qhimg.com/t01cca5849c98837396.jpg');
|
||||
renderer.uniforms.tMap = texture;
|
||||
const r = 0.2126,
|
||||
g = 0.7152,
|
||||
b = 0.0722;
|
||||
renderer.uniforms.colorMatrix = [
|
||||
r, r, r, 0,
|
||||
g, g, g, 0,
|
||||
b, b, b, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
renderer.setMeshData([{
|
||||
positions: [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
],
|
||||
attributes: {
|
||||
uv: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
],
|
||||
},
|
||||
cells: [[0, 1, 2], [2, 0, 3]],
|
||||
}]);
|
||||
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
还记得吗?上一节课我们也实现了一个颜色矩阵,那它们有什么区别呢?区别主要有两个。
|
||||
|
||||
首先,上一节课的颜色矩阵是一个4**5的矩阵,但是因为GLSL语法在数据类型上不能直接支持mat4(4**4)以上的矩阵,所以我们要计算4*5矩阵很不方便。而且在通常情况下,我们不经常处理颜色的alpha值,所以这里我就把alpha通道忽略了,只对RGB做矩阵变换,这样我们用mat4的齐次矩阵就够了。
|
||||
|
||||
其次,根据标准的矩阵与向量乘法的法则,应该是向量与矩阵的列相乘,所以我把这次传入的矩阵转置了一下,把按行排列的rgba换成按列排列,就得到了下面这个矩阵。
|
||||
|
||||
```
|
||||
renderer.uniforms.colorMatrix = [
|
||||
r, r, r, 0,
|
||||
g, g, g, 0,
|
||||
b, b, b, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
```
|
||||
|
||||
这样,我们就实现了与上一节课一样的图片灰度化的功能,它是使用片元着色器实现的,在性能上要远远高于Canvas2D。
|
||||
|
||||
### 3. 实现图片的粒子化
|
||||
|
||||
不过,用Shader只处理颜色滤镜就有些大材小用了,利用Shader的高性能我们可以实现一些更加复杂的效果,比如,给图片实现一个粒子化的渐显效果。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/ea/02df04608e76c0920ac9ef1525c42aea.gif" alt="">
|
||||
|
||||
这个视觉效果如果在Canvas2D中实现,需要大量的运算,非常耗费性能,几乎不太可能流畅地运行起来,但是在WebGL的Shader中就可以轻松做到。究竟是怎么做到的呢?
|
||||
|
||||
我们重点来看一下Fragment Shader的代码。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform float uTime;
|
||||
varying vec2 vUv;
|
||||
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * vec2(100, 55.4);
|
||||
vec2 uv = vUv + 1.0 - 2.0 * random(floor(st));
|
||||
vec4 color = texture2D(tMap, mix(uv, vUv, min(uTime, 1.0)));
|
||||
gl_FragColor.rgb = color.rgb;
|
||||
gl_FragColor.a = color.a * uTime;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码虽然不长,但如果你还不太熟悉Shader,可能一眼看去,很难直接了解具体的作用,不要紧,我们一步一步来看。
|
||||
|
||||
首先,我们使用第11节课学过的重复网格技巧,将图形网格化。因为原始图像的图片像素宽高是1000px和554px,所以我们用 vec2 st = vUv * vec2(100, 55.4) 就可以得到10px X 10px大小的网格。
|
||||
|
||||
然后,我们再用伪随机函数random 根据网格随机一个偏移量,因为这个偏移量是0~1之间的值,我们将它乘以2再用1减去它,就能得到一个范围在-1~1之间的随机偏移。这样我们从纹理取色的时候,不是直接从对应的纹理坐标vUv处取色,而是从这个随机偏移的位置取色,就能保证取出来的颜色就是一个乱序的色值。这时候,图片显示的效果是一片随机的画面:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/3d/20dc4c07e075da18a44975142e79913d.jpg" alt="">
|
||||
|
||||
接着,我们引入uTime变量,用mix函数对偏移后的uv和原始的vUv相对于时间变化进行插值。当初始时间为0的时候,取色从uv取;当时间超过一个周期的时候,取色从vUv取;当时间在中间时,取值介于uv和vUv之间。
|
||||
|
||||
最后,我们再把uTime也和透明度关联起来。这样就实现了你上面看到的粒子化的渐显效果。
|
||||
|
||||
当然,这个效果做得其实还比较粗糙,因为我们引入的变量比较少,在后续的课程中,我们会一步一步深入,继续实现更加惊艳的效果。在课后,你也可以试着实现其他的效果,然后把你的成果分享出来。
|
||||
|
||||
### 4. 实现图像合成
|
||||
|
||||
除此之外,Fragment Shader还可以引入多纹理,让我们可以很方便地实现图像合成。比如说,对于在电影场景合成中比较常用的绿幕图片,我们就可以使用shader技术把它实时地合成到其他的图像上。
|
||||
|
||||
举个例子,假设我们有一只猫的绿幕图片:
|
||||
|
||||
举个例子,现在我们有一张带有猫的绿幕图片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/cc/20febddf9e1edbb9e7bd349e544f24cc.jpg" alt="" title="带有猫的绿幕图片">
|
||||
|
||||
我们要通过Fragment Shader将它合成到“高尔夫”那张照片上,具体的shader代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform sampler2D tCat;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
vec2 st = vUv * 3.0 - vec2(1.2, 0.5);
|
||||
vec4 cat = texture2D(tCat, st);
|
||||
|
||||
gl_FragColor.rgb = cat.rgb;
|
||||
if(cat.r < 0.5 && cat.g > 0.6) {
|
||||
gl_FragColor.rgb = color.rgb;
|
||||
}
|
||||
gl_FragColor.a = color.a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上面的代码所示,我们可以先通过tCat纹理获取绿幕图片。如果RGB通道中的G通道超过阈值,且R通道低于阈值,我们就可以接着把猫的图像从纹理中定位出来。然后经过缩放和平移变换等操作,我们就能把它放置到画面中适当的位置。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/dc/d2d1aacc6cc7c7c6a19c24ea821564dc.jpg" alt="">
|
||||
|
||||
## 要点总结
|
||||
|
||||
今天,我们讨论了边缘模糊和纹理叠加这两种滤镜,并且重点学习了用Shader加载纹理和实现滤镜的方法。
|
||||
|
||||
首先,我们知道了什么是边缘模糊,边缘模糊很容易实现,只要我们在遍历像素点的时候,同时计算当前像素点到图片中心点的距离,然后根据距离设置透明度,就可以实现边缘模糊的效果。
|
||||
|
||||
然后, 我们重点讲了Shader中的纹理叠加滤镜。
|
||||
|
||||
要实现这个滤镜,我们要先加载纹理,获取纹理的颜色。用Shader加载纹理的过程比较复杂,但我们可以使用一些封装好的库,如gl-renderer来简化纹理的加载。那在获取纹理的颜色的时候,我们可以通过texture2D函数读取纹理单元对应的uv坐标处的像素颜色。
|
||||
|
||||
加载了纹理之后呢,我们就可以通过纹理结合滤镜函数来处理像素,这就是纹理滤镜的应用场景了。通过纹理滤镜,我们不仅可以实现灰度化图片,还可以图片的粒子化渐显等等更加复杂的效果
|
||||
|
||||
除此之外,我们还可以使用shader加载多个纹理图片,把它们的颜色按照不同的方式进行叠加,从而实现图像合成。图像合成虽然在可视化中使用得比较少,但它非常适合用来实现一些特殊的视觉效果。
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
<li>
|
||||
你可以完善一下片元着色器中的颜色滤镜函数,实现灰度效果以外的效果吗?
|
||||
</li>
|
||||
<li>
|
||||
上节课,我们用Canvas2D实现了平滑效果滤镜,其实我们也可以用Fragment Shader结合纹理的形式把它实现出来,你能做到吗?
|
||||
</li>
|
||||
<li>
|
||||
如果我们想让一个图片的某个局部呈现“马赛克”效果,该用什么滤镜?你能把它实现出来吗?
|
||||
</li>
|
||||
|
||||
## 欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
|
||||
|
||||
## 源码
|
||||
|
||||
[课程示例代码](https://github.com/akira-cn/graphics/tree/master/pixels-shader)
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
[Texture的参数设置参考文档](https://zhuanlan.zhihu.com/p/68894334)
|
||||
654
极客时间专栏/跟月影学可视化/视觉基础篇/14 | 如何使用片元着色器进行几何造型?.md
Normal file
654
极客时间专栏/跟月影学可视化/视觉基础篇/14 | 如何使用片元着色器进行几何造型?.md
Normal file
@@ -0,0 +1,654 @@
|
||||
<audio id="audio" title="14 | 如何使用片元着色器进行几何造型?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/3f/4e80a2a0cb52e864f28d6ca9fa0c953f.mp3"></audio>
|
||||
|
||||
你好,我是月影。
|
||||
|
||||
在WebGL中,片元着色器有着非常强大的能力,它能够并行处理图片上的全部像素,让数以百万计的运算同时完成。但也正因为它是并行计算的,所以它和常规代码顺序执行或者串行执行过程并不一样。因此,在使用片元着色器实现某些功能的时候,我们要采用与常规的JavaScript代码不一样的思路。
|
||||
|
||||
到底哪里不一样呢?今天,我就通过颜色控制,以及线段、曲线、简单几何图形等的绘制,来讲讲片元着色器是怎么进行几何造型的,从而加深你对片元着色器绘图原理的理解。
|
||||
|
||||
首先,我们来说比较简单的颜色控制。
|
||||
|
||||
## 如何用片元着色器控制局部颜色?
|
||||
|
||||
我们知道,片元着色器能够用来控制像素颜色,最简单的就是把图片绘制为纯色。比如,通过下面的代码,我们就把一张图片绘制为了纯黑色。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vec4(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果想让一张图片呈现不同的颜色,我们还可以根据纹理坐标值来绘制,比如,通过下面的代码,我们就可以让某个图案的颜色,从左到右由黑向白过渡。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
gl_FragColor.rgb = vec3(vUv.x);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,这种颜色过渡还比较单一,这里我们还可以改变一下渲染方式让图形呈现的效果更复杂。比如说,我们可以使用乘法创造一个10*10的方格,让每个格子左上角是绿色,右下角是红色,中间是过渡色。代码和显示的效果如下所示:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * 10.0;
|
||||
gl_FragColor.rgb = vec3(fract(st), 0.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/f6/9b33fc3c5b08343114c479574f0484f6.jpeg" alt="">
|
||||
|
||||
不仅如此,我们还可以在上图的基础上继续做调整。我们可以通过idx = floor(st)获取网格的索引,判断网格索引除以2的余数(奇偶性),根据它来决定是否翻转网格内的x、y坐标。这样操作后的代码和图案如下所示:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * 10.0;
|
||||
vec2 idx = floor(st);
|
||||
vec2 grid = fract(st);
|
||||
|
||||
vec2 t = mod(idx, 2.0);
|
||||
|
||||
if(t.x == 1.0) {
|
||||
grid.x = 1.0 - grid.x;
|
||||
}
|
||||
if(t.y == 1.0) {
|
||||
grid.y = 1.0 - grid.y;
|
||||
}
|
||||
gl_FragColor.rgb = vec3(grid, 0.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/a2/66b0826578525073320c14e6120a8aa2.jpeg" alt="">
|
||||
|
||||
事实上,再改用不同的方式,我们还可以生成更多有趣的图案。不过,这里我们就不继续了,因为上面这些做法有点像是灵机一动的小技巧。实际上,我们缺少的并不是小技巧,而是一套统一的方法论。我们希望能够利用它,在着色器里精确地绘制出我们想要的几何图形。
|
||||
|
||||
## 如何用片元着色器绘制圆、线段和几何图形
|
||||
|
||||
那接下来,我们就通过几个例子,把片元着色器精确绘图的方法论给总结出来。
|
||||
|
||||
### 1. 绘制圆
|
||||
|
||||
首先,我们从最简单的几何图形,也就是圆开始,来说说片元着色器的绘图过程。
|
||||
|
||||
一般来说,我们画圆的时候是根据点坐标到圆心的距离来生成颜色的。在片元着色器中,我们可以用distance函数求一下vUv和画布中点vec2(0.5)的距离,然后根据这个值设置颜色。代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float d = distance(vUv, vec2(0.5));
|
||||
gl_FragColor.rgb = d * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过这样的方法,我们最终绘制出了一个模糊的圆,效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/58/84239f4aaf8f54cdbe93a65e3bfd2d58.jpeg" alt="">
|
||||
|
||||
为什么这个圆是模糊的呢?这是因为越靠近圆心,距离d的值越小, gl_FragColor.rgb = d * vec3(1.0); 的颜色值也就越接近于黑色。
|
||||
|
||||
那如果我们要实现一个更清晰的圆应该怎么做呢?这个时候,你别忘了还有step函数。我们用step函数基于0.2做阶梯,就能得到一个半径为0.2的圆。实现代码和最终效果如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float d = distance(vUv, vec2(0.5));
|
||||
gl_FragColor.rgb = step(d, 0.2) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/36/8b3acfbe004038caba9f9c1a1429ae36.jpeg" alt="">
|
||||
|
||||
不过,你会发现我们得到的这个圆的边缘很不光滑。这是因为浮点数计算的精度导致的锯齿现象。为了解决这个问题,我们用smoothstep代替step。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/c8/3f73b8cf75cf0c67cdb7815d0b7051c8.jpeg" alt="">
|
||||
|
||||
为什么smoothstep代替step就可以得到比较光滑的圆呢?这是因为smoothstep和step类似,都是阶梯函数。但是,与step的值是直接跳跃的不同,smoothstep在step-start和step-end之间有一个平滑过渡的区间。因此,用smoothstep绘制的圆,边缘就会有一圈颜色过渡,就能从视觉上消除锯齿。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/d7/52c7484a1e18526e2fca815c4083b5d7.jpeg" alt="">
|
||||
|
||||
片元着色器绘制的圆,在构建图像的粒子效果中比较常用。比如,我们可以用它来实现图片的渐显渐隐效果。下面是片元着色器中代码,以及我们最终能够实现的效果图。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uTime;
|
||||
varying vec2 vUv;
|
||||
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
uv.y *= uResolution.y / uResolution.x;
|
||||
vec2 st = uv * 100.0;
|
||||
float d = distance(fract(st), vec2(0.5));
|
||||
float p = uTime + random(floor(st));
|
||||
float shading = 0.5 + 0.5 * sin(p);
|
||||
d = smoothstep(d, d + 0.01, 1.0 * shading);
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
gl_FragColor.rgb = color.rgb * clamp(0.5, 1.3, d + 1.0 * shading);
|
||||
gl_FragColor.a = color.a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/9d/a6ffc10e75f217078c68c8dd6a7b4f9d.gif" alt="">
|
||||
|
||||
### 2. 绘制线
|
||||
|
||||
利用片元着色器绘制圆的思路,就是根据点到圆心的距离来设置颜色。实际上,我们也可以用同样的原理来绘制线,只不过需要把点到点的距离换成点到直线(向量)的距离。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec3 line = vec3(1, 1, 0);
|
||||
float d = abs(cross(vec3(vUv,0), normalize(line)).z);
|
||||
gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
比如,我们利用上面的代码,就能在画布上画出一条斜线。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/7f/618fc882f894386eccdcf4bdea233e7f.jpeg" alt="">
|
||||
|
||||
如果你还不能一眼看出上面的代码为什么能画出一条直线,说明你对于图形学的向量计算思维还没有完全适应。不过别着急,随着我们练习的增多,你会逐渐适应的。下面,我来解释一下这段代码。
|
||||
|
||||
这里,我们用一个三维向量line来定义一条直线。因为我们要绘制的是2D图形,所以z保持0就行,而x和y用来决定方向。
|
||||
|
||||
然后呢,我们求vUv和line的距离。这里我们直接用向量叉乘的性质就能求得。因为两个二维向量叉积的z轴分量的大小,就是这两个向量组成的平行四边形的面积,那当我们把line的向量归一化之后,这个值就是vUv到直线的距离d了。因为这个d带符号,所以我们还需要取它的绝对值。
|
||||
|
||||
最后,我们用这个d结合前面使用过的smoothstep来控制像素颜色,就能得到一条直线了。
|
||||
|
||||
### 3. 用鼠标控制直线
|
||||
|
||||
画出直线之后,我们改变line还可以得到不同的直线。比如,在着色器代码中,我们再添加一个uniform变量uMouse,就可以根据鼠标位置来控制直线方向。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform vec2 uMouse;
|
||||
|
||||
void main() {
|
||||
vec3 line = vec3(uMouse, 0); // 用向量表示所在直线
|
||||
float d = abs(cross(vec3(vUv,0), normalize(line)).z); // 叉乘表示平行四边形面积,底边为1,得到距离
|
||||
gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应地,我们需要在JavaScript中将uMouse通过uniforms传入,代码如下:
|
||||
|
||||
```
|
||||
const canvas = document.querySelector('canvas');
|
||||
const renderer = new GlRenderer(canvas);
|
||||
const program = renderer.compileSync(fragment, vertex);
|
||||
renderer.useProgram(program);
|
||||
|
||||
renderer.uniforms.uMouse = [-1, -1];
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
const {x, y, width, height} = e.target.getBoundingClientRect();
|
||||
renderer.uniforms.uMouse = [
|
||||
(e.x - x) / width,
|
||||
1.0 - (e.y - y) / height,
|
||||
];
|
||||
});
|
||||
|
||||
renderer.setMeshData([{
|
||||
positions: [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
],
|
||||
attributes: {
|
||||
uv: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
],
|
||||
},
|
||||
cells: [[0, 1, 2], [2, 0, 3]],
|
||||
}]);
|
||||
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/71/aa095666cfb32167aff86d051b929271.gif" alt="">
|
||||
|
||||
在上面的例子中,我们的直线是经过原点的。那如果我们想让直线经过任意的定点该怎么办?我们可以加一个uniform变量uOrigin,来表示直线经过的固定点。代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform vec2 uMouse;
|
||||
uniform vec2 uOrigin;
|
||||
|
||||
void main() {
|
||||
vec3 line = vec3(uMouse - uOrigin, 0); // 用向量表示所在直线
|
||||
float d = abs(cross(vec3(vUv - uOrigin, 0), normalize(line)).z); // 叉乘表示平行四边形面积,底边为1,得到距离
|
||||
gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
延续这个绘制直线的思路,我们很容易就能知道该如何绘制线段了。绘制线段与绘制直线的方法几乎一样,只不过,我们要将计算点到直线的距离修改为计算点到线段的距离。
|
||||
|
||||
但是因为点和线段之间有两种关系,一种是点在线段上,另一种是在线段之外。所以我们在求点到线段的距离d的时候,要分两种情况讨论:当点到线段的投影位于线段两个端点中间的时候,它就等于点到直线的距离;当点到线段的投影在两个端点之外的时候,它就等于这个点到最近一个端点的距离。
|
||||
|
||||
这么说还是比较抽象,我画了一个示意图。你会看到,C<sub>1</sub>到线段ab的距离就等于它到线段所在直线的距离,C<sub>2</sub>到线段ab的距离是它到a点的距离,C<sub>3</sub>到线段的距离是它到b点的距离。那么如何判断究竟是C<sub>1</sub>、C<sub>2</sub>、C<sub>3</sub>中的哪一种情况呢?答案是通过C<sub>1</sub>到线段ab的投影来判断。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/2b/64cb19cd9fabb219db3d185d3a77922b.jpeg" alt="">
|
||||
|
||||
所以,我们在原本片元着色器代码的基础上,抽象出一个seg_distance函数,用来返回点到线段的距离。修改后的代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform vec2 uMouse;
|
||||
uniform vec2 uOrigin;
|
||||
|
||||
float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
|
||||
vec3 ab = vec3(b - a, 0);
|
||||
vec3 p = vec3(st - a, 0);
|
||||
float l = length(ab);
|
||||
float d = abs(cross(p, normalize(ab)).z);
|
||||
float proj = dot(p, ab) / l;
|
||||
if(proj >= 0.0 && proj <= l) return d;
|
||||
return min(distance(st, a), distance(st, b));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float d = seg_distance(vUv, uOrigin, uMouse);
|
||||
gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这么修改之后,如果我们将uOrigin设为vec2(0.5, 0.5),就会得到如下效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/06/b4b5cf71a53c50b95a7e4abbee40d506.gif" alt="">
|
||||
|
||||
### 4. 绘制三角形
|
||||
|
||||
你可能已经发现了,不管是画圆还是画线,我们使用的原理都是求点到点或者是点到线段距离。实际上,这个原理还可以扩展应用到封闭平面图形的绘制上。那我们就以三角形为例,来说说片元着色器的绘制结合图形的方法。
|
||||
|
||||
首先,我们要判断点是否在三角形内部。我们知道,点到三角形三条边的距离有三个,只要这三个距离的符号都相同,我们就能确定点在三角形内。
|
||||
|
||||
然后,我们建立三角形的距离模型。我们规定它的内部距离为负,外部距离为正,并且都选点到三条边的最小距离。代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float line_distance(in vec2 st, in vec2 a, in vec2 b) {
|
||||
vec3 ab = vec3(b - a, 0);
|
||||
vec3 p = vec3(st - a, 0);
|
||||
float l = length(ab);
|
||||
return cross(p, normalize(ab)).z;
|
||||
}
|
||||
|
||||
float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
|
||||
vec3 ab = vec3(b - a, 0);
|
||||
vec3 p = vec3(st - a, 0);
|
||||
float l = length(ab);
|
||||
float d = abs(cross(p, normalize(ab)).z);
|
||||
float proj = dot(p, ab) / l;
|
||||
if(proj >= 0.0 && proj <= l) return d;
|
||||
return min(distance(st, a), distance(st, b));
|
||||
}
|
||||
|
||||
float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
|
||||
float d1 = line_distance(st, a, b);
|
||||
float d2 = line_distance(st, b, c);
|
||||
float d3 = line_distance(st, c, a);
|
||||
|
||||
if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
|
||||
return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
|
||||
}
|
||||
|
||||
return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
|
||||
}
|
||||
|
||||
void main() {
|
||||
float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
|
||||
gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,我们就绘制出了一个白色的三角形。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/9b/34ae3dfdca357d9207ea42e02d1f269b.jpeg" alt="">
|
||||
|
||||
实际上,三角形的这种画法还可以推广到任意凸多边形。比如,矩形和正多边形就可以使用同样的方式来绘制。
|
||||
|
||||
## 片元着色器绘图方法论:符号距离场渲染
|
||||
|
||||
现在,你应该知道这些基本的线段、圆和几何图形该怎么绘制了。那我们能不能从中总结出一套统一的方法论呢?我们发现,前面绘制的图形虽然各不相同,但是它们的绘制步骤都可以总结为以下两步。
|
||||
|
||||
**第一步:定义距离**。这里的距离,是一个人为定义的概念。在画圆的时候,它指的是点到圆心的距离;在画直线和线段的时候,它是指点到直线或某条线段的距离;在画几何图形的时候,它是指点到几何图形边的距离。
|
||||
|
||||
**第二步:根据距离着色**。首先是用smoothstep方法,选择某个范围的距离值,比如在画直线的时候,我们设置smoothstep(0.0, 0.01, d),就表示选取距离为0.0到0.01的值。然后对这个范围着色,我们就可以将图形的边界绘制出来了。
|
||||
|
||||
延续这个思路,我们还可以选择距离在0.0~0.01范围以外的点。下面,我们做一个小实验。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec3 line = vec3(1, 1, 0);
|
||||
float d = abs(cross(vec3(vUv,0), normalize(line)).z);
|
||||
gl_FragColor.rgb = (smoothstep(0.195, 0.2, d) - smoothstep(0.2, 0.205, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们用之前绘制直线的代码,将 gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0) 修改为 gl_FragColor.rgb = (smoothstep(0.195, 0.2, d) - smoothstep(0.2, 0.205, d)) * vec3(1.0),我们看到输出的结果变成对称的两条直线了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/c9/dcfb68cb31eae8c8f99dbc663a207dc9.jpeg" alt="">
|
||||
|
||||
这是为什么呢?因为我们是对距离原直线0.2处的点进行的着色,那实际上距离0.2的点有两条线,所以就能绘制出两条直线了。我把它的原理画了一个示意图,你可以看看,其中红线是原直线。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/e5/ba296d4490d0a0c699f6ecc739e003e5.jpeg" alt="">
|
||||
|
||||
利用这个思路,再加上使用乘法和fract函数重复绘制的原理,我们就可以绘制多条平行线了。比如通过下面的代码,我们可以绘制出均匀的平面分割线。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec3 line = vec3(1, 1, 0);
|
||||
float d = abs(cross(vec3(vUv,0), normalize(line)).z);
|
||||
d = fract(20.0 * d);
|
||||
gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/28/2ac38ebaf3699b4d1edebf4eecc19b28.jpeg" alt="">
|
||||
|
||||
利用同样的办法,我们还可以绘制圆环或者三角环或者其他图形的环。因为原理相同,下面我就直接给你展示代码和效果了。
|
||||
|
||||
首先是绘制圆环的代码和效果:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float d = distance(vUv, vec2(0.5));
|
||||
d = fract(20.0 * d);
|
||||
gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/49/04b066db1e7b9ea0003df73e64fcbf49.jpeg" alt="">
|
||||
|
||||
然后是绘制三角环的代码和效果:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float line_distance(in vec2 st, in vec2 a, in vec2 b) {
|
||||
vec3 ab = vec3(b - a, 0);
|
||||
vec3 p = vec3(st - a, 0);
|
||||
float l = length(ab);
|
||||
return cross(p, normalize(ab)).z;
|
||||
}
|
||||
|
||||
float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
|
||||
vec3 ab = vec3(b - a, 0);
|
||||
vec3 p = vec3(st - a, 0);
|
||||
float l = length(ab);
|
||||
float d = abs(cross(p, normalize(ab)).z);
|
||||
float proj = dot(p, ab) / l;
|
||||
if(proj >= 0.0 && proj <= l) return d;
|
||||
return min(distance(st, a), distance(st, b));
|
||||
}
|
||||
|
||||
float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
|
||||
float d1 = line_distance(st, a, b);
|
||||
float d2 = line_distance(st, b, c);
|
||||
float d3 = line_distance(st, c, a);
|
||||
|
||||
if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
|
||||
return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
|
||||
}
|
||||
|
||||
return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
|
||||
}
|
||||
|
||||
void main() {
|
||||
float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
|
||||
d = fract(20.0 * abs(d));
|
||||
gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/0f/c682ed12d9f148261d8ba076209eb90f.jpeg" alt="">
|
||||
|
||||
实际上,上面这种绘制图形和环的方式,在图形渲染中有一个专有的名称叫做**符号距离场渲染**(Signed Distance Fields Rendering)。它本质上就是利用空间中的距离分布来着色的。我们把上面的三角环代码换一种渲染方式,你就能看得更清楚一些了。代码如下:
|
||||
|
||||
```
|
||||
void main() {
|
||||
float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
|
||||
d = fract(20.0 * abs(d));
|
||||
gl_FragColor.rgb = vec3(d);
|
||||
// gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们在渲染的时候,还可以把main函数中原来的smoothstep渲染方式注释掉,直接用vec3(d)来渲染颜色,就会得到的如下的效果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/84/3eaf4531819d34a61e9ea5aacef4cd84.jpeg" alt="">
|
||||
|
||||
你能看到,这里的每一环,两两之间的距离是沿着法线方向从0到1的,所以颜色从黑色过渡到白色,这就是三角环的距离场分布。相同颜色值的环线就是距离场的等距线,我们用step或smoothstep的方式将某些等距线的颜色设置为白色,其他位置颜色设置为黑色,就绘制出之前的环线效果来了。
|
||||
|
||||
## 着色器绘制几何图形的用途
|
||||
|
||||
讨论到这里,你一定有些疑惑,我们学习这些片元着色器的绘图方式,究竟有什么实际用途呢?实际上它的用途还是挺广泛的,在这里我想先简单举几个实际的应用案例,你可以先感受一下。
|
||||
|
||||
不过,在讲具体案例之前,我还想多啰嗦几句。着色器造型是着色器的一种非常基础的使用方法,甚至可以说是图形学中着色器渲染最基础的原理,就好比代数的基础是四则运算一样,它构成了GPU视觉渲染的基石,我们在视觉呈现中生成的各种细节特效的方法,万变不离其宗,基本上都和着色器造型有关。
|
||||
|
||||
所以呢,我希望你不仅仅要了解它的用途,更要彻底弄明白它的原理和思路,尤其是非常重要的符号距离场渲染技巧,一定要理解并熟练掌握。关于着色器造型的更多、更复杂的应用场景,我们在后续的课程中还会遇到。明白了这一点,我们接着来看三个简单的案例吧。
|
||||
|
||||
首先,我们可以用着色器造型实现图像的剪裁。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform float uTime;
|
||||
|
||||
...
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
vec2 uv = vUv - vec2(0.5);
|
||||
vec2 a = vec2(-0.577, 0) - vec2(0.5);
|
||||
vec2 b = vec2(0.5, 1.866) - vec2(0.5);
|
||||
vec2 c = vec2(1.577, 0) - vec2(0.5);
|
||||
|
||||
float scale = min(1.0, 0.0005 * uTime);
|
||||
float d = triangle_distance(uv, scale * a, scale * b, scale * c);
|
||||
gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * color.rgb;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
利用上面的代码,我们对图像进行三角形剪裁,可以实现的效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/54/877b37250d0e2da82b3e72fcd83b3754.gif" alt="">
|
||||
|
||||
其次,我们可以实现对图像的动态修饰,比如类似下面这种进度条。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform float uTime;
|
||||
|
||||
...
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
vec2 uv = vUv - vec2(0.5);
|
||||
vec2 a = vec2(0, 1);
|
||||
float time = 0.0005 * uTime;
|
||||
|
||||
vec2 b = vec2(sin(time), cos(time));
|
||||
float d = 0.0;
|
||||
|
||||
float c0 = cross(vec3(b, 0.0), vec3(a, 0.0)).z;
|
||||
float c1 = cross(vec3(uv, 0.0), vec3(a, 0.0)).z;
|
||||
float c2 = cross(vec3(uv, 0.0), vec3(b, 0.0)).z;
|
||||
if(c0 > 0.0 && c1 > 0.0 && c2 < 0.0) {
|
||||
d = 1.0;
|
||||
}
|
||||
if(c0 < 0.0 && (c1 >= 0.0 || c2 <= 0.0)) {
|
||||
d = 1.0;
|
||||
}
|
||||
|
||||
gl_FragColor.rgb = color.rgb;
|
||||
gl_FragColor.r *= mix(0.3, 1.0, d);
|
||||
gl_FragColor.a = mix(0.9, 1.0, d);
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/54/f07b6c187786c59029ab626c5b605254.gif" alt="">
|
||||
|
||||
第三,我们还可以在一些3D场景中修饰几何体,比如像这样给一个球体套一个外壳,这个例子的代码我就不贴出来了,在后续3D课程中我们再详细来说。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/09/4f65c00dd8fa1a58bf7128a987d31109.gif" alt="">
|
||||
|
||||
## 要点总结
|
||||
|
||||
这一节课,我们学习了使用片元着色器进行几何造型的2种常用方法。
|
||||
|
||||
首先,用片元着色器可以通过控制局部颜色来绘制图案,比如根据像素坐标来控制颜色变化,然后利用重复绘制的技巧,形成有趣的图案花纹。
|
||||
|
||||
其次,我们可以定义并计算像素坐标的距离,然后根据距离来填充颜色,这种方法实际上叫做符号距离场渲染,是着色器造型生成图案的基础方法。通过这种方法我们可以绘制圆、直线、线段、三角形以及其他图形。
|
||||
|
||||
使用着色器绘制几何图形是WebGL常用的方式,它有许多用途,比如可以剪裁图像、显示进度条、实现外壳纹路等等,因此在可视化中有许多使用场景。
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
这一节课我们介绍了圆、直线、线段和三角形的基本画法,其他图形也可以用t方法来绘制。试着用同样的思路来绘制正方形、正六角星、椭圆吧!
|
||||
|
||||
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
|
||||
|
||||
## 源码
|
||||
|
||||
[本节课完整代码.](https://github.com/akira-cn/graphics/tree/master/shaping-functions)
|
||||
414
极客时间专栏/跟月影学可视化/视觉基础篇/15 | 如何用极坐标系绘制有趣图案?.md
Normal file
414
极客时间专栏/跟月影学可视化/视觉基础篇/15 | 如何用极坐标系绘制有趣图案?.md
Normal file
@@ -0,0 +1,414 @@
|
||||
<audio id="audio" title="15 | 如何用极坐标系绘制有趣图案?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ce/cb/ce85fcf7400a819dab5de26f7d47fdcb.mp3"></audio>
|
||||
|
||||
你好,我是月影。
|
||||
|
||||
在前面的课程中,我们一直是使用直角坐标系来绘图的。但在图形学中,除了直角坐标系之外,还有一种比较常用的坐标系就是极坐标系。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/b6/31/b62312e2af6385ffcdb1d3dab4fdd731.jpeg" alt="" title="极坐标示意图">](http://zh.wikipedia.org)
|
||||
|
||||
你对极坐标系应该也不陌生,它是一个二维坐标系。与二维直角坐标系使用x、y分量表示坐标不同,极坐标系使用相对极点的距离,以及与x轴正向的夹角来表示点的坐标,如(3,60°)。
|
||||
|
||||
在图形学中,极坐标的应用比较广泛,它不仅可以简化一些曲线方程,甚至有些曲线只能用极坐标来表示。不过,虽然用极坐标可以简化许多曲线方程,但最终渲染的时候,我们还是需要转换成图形系统默认支持的直角坐标才可以进行绘制。在这种情况下,我们就必须要知道直角坐标和极坐标是怎么相互转换的。两个坐标系具体转换比较简单,我们可以用两个简单的函数,toPolar和fromPolar来实现,函数代码如下:
|
||||
|
||||
```
|
||||
// 直角坐标影射为极坐标
|
||||
function toPolar(x, y) {
|
||||
const r = Math.hypot(x, y);
|
||||
const θ= Math.atan2(y, x);
|
||||
return [r, θ];
|
||||
}
|
||||
|
||||
// 极坐标映射为直角坐标
|
||||
function fromPolar(r, θ) {
|
||||
const x = r * cos(θ);
|
||||
const y = r * sin(θ);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
那今天,我们就通过参数方程结合极坐标,来绘制一些不太好用直角坐标系绘制的曲线,让你认识极坐标的优点,从而帮助你掌握极坐标的用法。
|
||||
|
||||
## 如何用极坐标方程绘制曲线
|
||||
|
||||
在[第6节课](https://time.geekbang.org/column/article/256827)中,为了更方便地绘制曲线,我们用parametric.js函数实现了一个参数方程的绘图模块,它非常方便。所以在使用极坐标方程绘制曲线的时候,我们也要用到parametric.js函数。不过,在使用之前,我们还要对它进行扩展,让它支持坐标映射。这样,我们就可以写出对应的坐标映射函数,从而将极坐标映射为绘图需要的直角坐标了。
|
||||
|
||||
具体的操作就是,给parametric增加一个参数**rFunc**。rFunc是一个坐标映射函数,通过它我们可以将任意坐标映射为直角坐标,修改后的代码如下:
|
||||
|
||||
```
|
||||
export function parametric(sFunc, tFunc, rFunc) {
|
||||
return function (start, end, seg = 100, ...args) {
|
||||
const points = [];
|
||||
for(let i = 0; i <= seg; i++) {
|
||||
const p = i / seg;
|
||||
const t = start * (1 - p) + end * p;
|
||||
const x = sFunc(t, ...args);
|
||||
const y = tFunc(t, ...args);
|
||||
if(rFunc) {
|
||||
points.push(rFunc(x, y));
|
||||
} else {
|
||||
points.push([x, y]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
draw: draw.bind(null, points),
|
||||
points,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这里,你可能想问,直角坐标和极坐标转换的函数,我们在一开始不是已经讲过了吗?为什么这里又要拓展一个rFunc参数呢?其实啊,开头我给出的函数虽然足够简单,但不够灵活,也不便于扩展。而先使用rFunc来抽象坐标映射,再把其他函数作为rFunc参数传给parametric,是一种更通用的坐标映射方法,它属于函数式编程思想。
|
||||
|
||||
说到这,我再多说几句。虽然函数式设计思想不是我们这个课程的核心,但它对框架和库的设计很重要,所以,我讲它也是希望你能通过这个例子,尽可能地理解代码中的精髓,学会使用最佳的设计方法和思路去解决问题,获得更多额外的收获,而不只是去理解眼前的基本概念。
|
||||
|
||||
那接下来,我们用极坐标参数方程画一个半径为200的半圆。在这里,我们把fromPolar作为rFunc参数传给parametric,就可以使用极坐标的参数方程来绘制图形了,代码如下所示。
|
||||
|
||||
```
|
||||
const fromPolar = (r, θ) => {
|
||||
return [r * Math.cos(θ), r * Math.sin(θ)];
|
||||
};
|
||||
|
||||
const arc = parametric(
|
||||
t => 200,
|
||||
t => t,
|
||||
fromPolar,
|
||||
);
|
||||
|
||||
arc(0, Math.PI).draw(ctx);
|
||||
|
||||
|
||||
```
|
||||
|
||||
此外,我们还可以添加其他的极坐标参数方程来绘制更多曲线,比如玫瑰线、心形线或者双纽线。因为这些操作都比较简单,我就直接在下面给出代码了。
|
||||
|
||||
```
|
||||
const rose = parametric(
|
||||
(t, a, k) => a * Math.cos(k * t),
|
||||
t => t,
|
||||
fromPolar,
|
||||
);
|
||||
|
||||
rose(0, Math.PI, 100, 200, 5).draw(ctx, {strokeStyle: 'blue'});
|
||||
|
||||
const heart = parametric(
|
||||
(t, a) => a - a * Math.sin(t),
|
||||
t => t,
|
||||
fromPolar,
|
||||
);
|
||||
|
||||
heart(0, 2 * Math.PI, 100, 100).draw(ctx, {strokeStyle: 'red'});
|
||||
|
||||
const foliumRight = parametric(
|
||||
(t, a) => Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
|
||||
t => t,
|
||||
fromPolar,
|
||||
);
|
||||
|
||||
const foliumLeft = parametric(
|
||||
(t, a) => -Math.sqrt(2 * a ** 2 * Math.cos(2 * t)),
|
||||
t => t,
|
||||
fromPolar,
|
||||
);
|
||||
|
||||
foliumRight(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'});
|
||||
foliumLeft(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'});
|
||||
|
||||
```
|
||||
|
||||
最终,我们能够绘制出如下的效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/fa/475905a6708e51yy234c640f292833fa.jpeg" alt="">
|
||||
|
||||
总的来说,我们看到,使用极坐标系中参数方程来绘制曲线的方法,其实和我们学过的直角坐标系中参数方程绘制曲线差不多,唯一的区别就是在具体实现的时候,我们需要额外增加一个坐标映射函数,将极坐标转为直角坐标才能完成最终的绘制。
|
||||
|
||||
## 如何使用片元着色器与极坐标系绘制图案?
|
||||
|
||||
在前面的例子中,我们主要还是通过参数方程来绘制曲线,用Canvas2D进行渲染。那如果我们使用shader来渲染,又该怎么使用极坐标系绘图呢?
|
||||
|
||||
这里,我们还是以圆为例,来看一下用shader渲染,再以极坐标画圆的做法。你可以先尝试自己理解下面的代码,然后再看我后面的讲解。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
vec2 polar(vec2 st) {
|
||||
return vec2(length(st), atan(st.y, st.x));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
gl_FragColor.rgb = smoothstep(st.x, st.x + 0.01, 0.2) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,我们先通过坐标转换公式实现polar函数。这个函数作用是将直角坐标转换为极坐标,相当于课程一开始,我们用JavaScript写的toPolar函数。这里有一个细节需要注意,我们使用的是GLSL内置的float atan(float, float)方法,对应的方法是Math.atan,而在JavaScript版本的toPolar函数中,对应的方法是Math.atan2。
|
||||
|
||||
然后,我们将像素坐标转换为极坐标:st = polar(st); ,转换后的st.x实际上是极坐标的r分量,而st.y就是极坐标的θ分量。
|
||||
|
||||
我们知道,对于极坐标下过极点的圆,实际上的r值就是一个常量值,对应圆的半径,所以我们取smoothstep(st.x, st.x + 0.01, 0.2),就能得到一个半径为0.2的圆了。这一步,我们用的还是上节课的**距离场**方法。只不过,在直角坐标系下,点到圆心的距离d需要用x、y平方和的开方来计算,而在极坐标下,点的极坐标r值正好表示了点到圆心的距离d,所以计算起来就比直角坐标系简单了很多。
|
||||
|
||||
其实,我们无论是用直角坐标还是极坐标来画图,方法都差不多。但是,一些其他的曲线用极坐标绘制会很方便。比如说,要绘制玫瑰线,我们就可以用以下代码:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
float d = 0.5 * cos(st.y * 3.0) - st.x;
|
||||
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,在画布上绘制出来的结果是三瓣玫瑰线:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/8a/f73a97f5f742dd5d9c2c53b8ecf5908a.jpeg" alt="">
|
||||
|
||||
可能你还是会有疑问,为什么d = 0.5 * cos(st.y * 3.0) - st.x; 绘制出的图形就是三瓣玫瑰线的图案呢?
|
||||
|
||||
这是因为玫瑰线的极坐标方程r = a * cos(k * θ),所以玫瑰线上的所有点都满足0 = a * cos(k * θ) - r 这个方程式。如果我们再把它写成距离场的形式:d = a * cos(k * θ) - r。这个时候就有三种情况:玫瑰线上点的 d 等于 0;玫瑰线围出的图形外的点的 d 小于0,玫瑰线围出的图形内的点的 d 大于 0。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/a9/7244ff9e7d36b8dd5ayy04e42430a5a9.jpeg" alt="">
|
||||
|
||||
因此,smoothstep(-0.01, 0.01, d) 能够将 d >= 0,也就是玫瑰线内的点选出来,这样也就绘制出了三瓣图形。
|
||||
|
||||
那玫瑰线有什么用呢?它是一种很有趣图案,我们只要修改u_k的值,并且保证它是正整数,就可以绘制出不同瓣数的玫瑰线图案。
|
||||
|
||||
```
|
||||
uniform float u_k;
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
float d = 0.5 * cos(st.y * u_k) - st.x;
|
||||
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
|
||||
renderer.uniforms.u_k = 3;
|
||||
|
||||
setInterval(() => {
|
||||
renderer.uniforms.u_k += 2;
|
||||
}, 200);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/18/0fa365713c9676219e72cd55073f7318.gif" alt="">
|
||||
|
||||
类似的图案还有花瓣线:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
float d = 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x;
|
||||
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在u_k=3的时候,我们可以得到如下图案:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/47/e51fc1ca89f103b3f949477424f18047.jpeg" alt="">
|
||||
|
||||
有趣的是,它和玫瑰线不一样,u_k的取值不一定要是整数。这让它能绘制出来的图形更加丰富,比如说我们可以取u_k=1.3,这时得到的图案就像是一个横放的苹果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/96/ff98434df974b610078f76aab6c96896.jpeg" alt="">
|
||||
|
||||
在此基础上,我们还可以再添加几个uniform变量,如u_scale、u_offset作为参数,来绘制出更多图形。代码如下:
|
||||
|
||||
```
|
||||
varying vec2 vUv;
|
||||
uniform float u_k;
|
||||
uniform float u_scale;
|
||||
uniform float u_offset;
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
float d = u_scale * 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x + u_offset;
|
||||
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当我们取u_k=1.7,u_scale=0.5,u_offset=0.2时,就能得到一个横置的葫芦图案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/74/5b303d4e6e7afd2f2bb61f10e9717574.jpeg" alt="">
|
||||
|
||||
如果我们继续修改 d 的计算方程,还能绘制出其他有趣的图形。
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
float d = smoothstep(-0.3, 1.0, u_scale * 0.5 * cos(st.y * u_k) + u_offset) - st.x;
|
||||
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
比如,当继续修改 d 的计算方程时,我们可以绘制出花苞图案:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/e9/8e87d4e5c76a06645860819474a25fe9.jpeg" alt="">
|
||||
|
||||
方法已经知道了,你可以在课后结合三角函数、abs、smoothstep,来尝试绘制一些更有趣的图案。如果有什么特别好玩的图案,你也可以分享出来。
|
||||
|
||||
## 极坐标系如何实现角向渐变?
|
||||
|
||||
除了绘制有趣的图案之外,极坐标的另一个应用是**角向渐变**(Conic Gradients)。那角向渐变是什么呢?如果你对CSS比较熟悉,一定知道角向渐变就是以图形中心为轴,顺时针地实现渐变效果。而且新的 [CSS Image Values and Replaced Content](https://www.w3.org/TR/css-images-4/#conic-gradients) 标准 level4 已经添加了角向渐变,我们可以使用它来创建一个基于极坐标的颜色渐变,代码如下:
|
||||
|
||||
```
|
||||
div.conic {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(red 0%, green 45%, blue);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/3e/6bdeda39bcbff4b2269d641df8f9d33e.jpeg" alt="">
|
||||
|
||||
我们可以通过角向渐变创建一个颜色由角度过渡的元素。在WebGL中,我们可以通过极坐标用片元着色器实现类似的角向渐变效果,代码如下:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
float d = smoothstep(st.x, st.x + 0.01, 0.2);
|
||||
// 将角度范围转换到0到2pi之间
|
||||
if(st.y < 0.0) st.y += 6.28;
|
||||
// 计算p的值,也就是相对角度,p取值0到1
|
||||
float p = st.y / 6.28;
|
||||
if(p < 0.45) {
|
||||
// p取0到0.45时从红色线性过渡到绿色
|
||||
gl_FragColor.rgb = d * mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p / 0.45);
|
||||
} else {
|
||||
// p超过0.45从绿色过渡到蓝色
|
||||
gl_FragColor.rgb = d * mix(vec3(0, 0.5, 0), vec3(0, 0, 1.0), (p - 0.45) / (1.0 - 0.45));
|
||||
}
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,我们将像素坐标转变为极坐标之后,st.y就是与x轴的夹角。因为polar函数里计算的atan(y, x)的取值范围是-π到π,所以我们在st.y小于0的时候,将它加上2π,这样就能把取值范围转换到0到2π了。
|
||||
|
||||
然后,我们根据角度换算出对应的比例对颜色进行线性插值。比如,比例在0%~45%之间,我们让颜色从红色过渡为绿色,那在45%到100%之间,我们让颜色从绿色过渡到蓝色。这样,我们最终就会得到如下效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/19/678161af2d8ff7ee9029bc9116cc0219.jpeg" alt="">
|
||||
|
||||
这个效果与CSS角向渐变得到的基本上一致,除了CSS角向渐变的起始角度是与Y轴的夹角,而shader是与X轴的夹角以外,没有其他的不同。这样,我们就可以在WebGL中利用极坐标系实现与CSS角向渐变一致的视觉效果了。
|
||||
|
||||
## 极坐标如何绘制HSV色轮?
|
||||
|
||||
想要实现丰富的视觉效果离不开颜色,通过前面的课程,我们已经知道各种颜色的表示方法,为了更方便地调试颜色,我们可以进一步来实现色轮。什么是色轮呢?色轮可以帮助我们,把某种颜色表示法所能表示的所有颜色方便、直观地显示出来。
|
||||
|
||||
那在WebGL中,我们该怎么绘制HSV色轮呢?我们可以用极坐标结合HSV颜色来绘制它。
|
||||
|
||||
接下来,就让我们一起在片元着色器中实现它吧。实现的过程其实并不复杂,我们只需要将像素坐标转换为极坐标,再除以2π,就能得到HSV的H值。然后我们用鼠标位置的x、y坐标来决定S和V的值,完整的片元着色器代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform vec2 uMouse;
|
||||
|
||||
vec3 hsv2rgb(vec3 c){
|
||||
vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
|
||||
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
|
||||
return c.z * mix(vec3(1.0), rgb, c.y);
|
||||
}
|
||||
|
||||
vec2 polar(vec2 st) {
|
||||
return vec2(length(st), atan(st.y, st.x));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st = polar(st);
|
||||
float d = smoothstep(st.x, st.x + 0.01, 0.2);
|
||||
if(st.y < 0.0) st.y += 6.28;
|
||||
float p = st.y / 6.28;
|
||||
gl_FragColor.rgb = d * hsv2rgb(vec3(p, uMouse.x, uMouse.y));
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最终的效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/bf/3998fd1107b9c234a28eeee1bb11fabf.gif" alt="">
|
||||
|
||||
## 圆柱坐标与球坐标
|
||||
|
||||
最后,我还想和你说说极坐标和圆柱坐标系以及球坐标系之间的关系。我们知道极坐标系是二维坐标系,如果我们将极坐标系延z轴扩展,可以得到圆柱坐标系。圆柱坐标系是一种三维坐标系,可以用来绘制一些三维曲线,比如螺旋线、圆内螺旋线、费马曲线等等。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/1d/10/1d697208453d1a9557a659b1f9c5db10.jpeg" alt="" title="圆柱坐标系">](https://zh.wikipedia.org/)
|
||||
|
||||
因为极坐标系可以和直角坐标系相互转换,所以直角坐标系和圆柱坐标系也可以相互转换,公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/ef/86a5b3052493841f8ec648eb260b17ef.jpg" alt="">
|
||||
|
||||
从上面的公式中你会发现,我们只转换了x、y的坐标,因为它们是极坐标,而z的坐标因为本身就是直角坐标不用转换。因此圆柱坐标系又被称为**半极坐标系。**
|
||||
|
||||
在此基础上,我们还可以进一步将圆柱坐标系转为球坐标系。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/a3/c1/a3ba1a1bb31090ffa90887907ee65ec1.jpeg" alt="" title="球坐标系">](https://zh.wikipedia.org)
|
||||
|
||||
同样地,圆柱坐标系也可以和球坐标系相互转换,公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/a9/8262a74b379f8433f1326e851ed579a9.jpg" alt="">
|
||||
|
||||
球坐标系在三维图形绘制、球面定位、碰撞检测等等可视化实现时都很有用,在后续的课程中,我们会有机会用到球坐标系,在这里你需要先记住它的转换公式。
|
||||
|
||||
## 要点总结
|
||||
|
||||
这一节课,我们学习了一个新的坐标系统也就是极坐标系,并且理解了直角坐标系与极坐标系的相互转换。
|
||||
|
||||
极坐标系是使用相对极点的距离,以及与x轴正向的夹角来表示点的坐标。极坐标系想要转换为直角坐标系需要用到fromPolar函数,反过来需要用到toPolar函数。
|
||||
|
||||
那在具体使用极坐标来绘制曲线的时候,有两种渲染方式。第一种是用Cavans渲染,这时候,我们可以用到之前学过的parametric高阶函数,将极坐标参数方程和坐标映射函数fromPolar传入,得到绘制曲线的函数,再用它来执行绘制。这样,极坐标系就能实现直角坐标系不太好描述的曲线了,比如,玫瑰线、心形线等等。
|
||||
|
||||
第二种是使用shader渲染,一般的方法是先将像素坐标转换为极坐标,然后使用极坐标构建距离场并着色。它能实现更多复杂的图案。
|
||||
|
||||
除了绘图,使用极坐标还可以实现角向渐变和HSV色轮。角向渐变通常可以用在构建饼图,而HSV色轮一般用在颜色可视化和择色交互等场合里。
|
||||
|
||||
此外,你还需要了解圆柱坐标、球坐标与直角坐标系的相互转换。在后续课程里,我们会使用圆柱坐标或球坐标来处理三维图形,到时候它们会非常有用。
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
<li>
|
||||
用极坐标绘制小图案时,我们绘制了苹果和葫芦的图案,但它们是横置的。你可以试着修改它们,让它们的方向变为正向吗?具体怎么做呢?
|
||||
</li>
|
||||
<li>
|
||||
在角向渐变的例子中,CSS角向渐变是与Y轴的夹角,而使用着色器绘制的版本是与X轴的夹角。那如果要让着色器绘制版本的效果与CSS角向渐变效果完全一致,我们该怎么做呢?
|
||||
</li>
|
||||
<li>
|
||||
我们已经学过了随机数、距离场以及极坐标,你是不是可以利用它们绘制出一个画布,并且呈现随机的剪纸图案,类似的效果如下所示。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/8e/d899fd39482d17ff720c3f86d5d5858e.jpeg" alt="">
|
||||
|
||||
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
|
||||
|
||||
## 源码
|
||||
|
||||
[parametric-shader](https://github.com/akira-cn/graphics/tree/master/parametric-polar)<br>
|
||||
[ploar-shader](https://github.com/akira-cn/graphics/tree/master/polar-shader)
|
||||
493
极客时间专栏/跟月影学可视化/视觉基础篇/16 | 如何使用噪声生成复杂的纹理?.md
Normal file
493
极客时间专栏/跟月影学可视化/视觉基础篇/16 | 如何使用噪声生成复杂的纹理?.md
Normal file
@@ -0,0 +1,493 @@
|
||||
<audio id="audio" title="16 | 如何使用噪声生成复杂的纹理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/6e/5c1593fa357775354d627732a643fe6e.mp3"></audio>
|
||||
|
||||
你好,我是月影。
|
||||
|
||||
在[第11节课](https://time.geekbang.org/column/article/262330)中,我们使用随机技巧生成噪点、迷宫等复杂图案。它们的作用都是表达数据和增强视觉效果。要想在可视化视觉呈现中实现更加酷炫的视觉效果,我们经常需要生成能够模拟大自然的、丰富而复杂的纹理图案。
|
||||
|
||||
那么这节课,我们就继续来讨论,如何使用随机技巧来生成更加复杂的纹理图案。
|
||||
|
||||
## 什么是噪声?
|
||||
|
||||
我们先来回忆一下,随机效果是怎么生成的。在第11节课中,我们使用一个离散的二维伪随机函数,随机生成了一片带有噪点的图案。代码和最终效果如下:
|
||||
|
||||
```
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/8e/3a539a23b70f8ca34a3c126139035d8e.jpeg" alt="">
|
||||
|
||||
然后,我们用取整的技巧,将这个图案局部放大,就呈现出了如下的方格状图案:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/2a/4082865db53e073b31520b9cyy90642a.jpeg" alt="">
|
||||
|
||||
在真实的自然界中,这种离散的随机是存在的,比如鸟雀随机地鸣叫,蝉鸣随机地响起再停止,雨滴随机地落在某个位置等等。但随机和连续并存是更常见的情况,比如山脉的走向是随机的,山峰之间的高度又是连续,类似的还有天上的云朵、水流的波纹、被侵蚀的土地等等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/c0/0441979299f96d57f2a6c87d0c9f08c0.jpeg" alt="">
|
||||
|
||||
因此,要模拟这些真实自然的图形,我们就需要把随机和连续结合起来,这样就形成了**噪声**(Noise)。
|
||||
|
||||
## 如何实现噪声函数?
|
||||
|
||||
随机和连续究竟是怎么合成的呢?换句话说,噪声函数是怎么实现的呢?
|
||||
|
||||
因为随机数是离散的,那如果我们对离散的随机点进行插值,可以让每个点之间的值连续过渡。因此,我们用smoothstep或者用平滑的三次样条来插值,就可以形成一条连续平滑的随机曲线。
|
||||
|
||||
下面,我们就通过生成折线的小例子来验证一下。代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
varying vec2 vUv;
|
||||
|
||||
// 随机函数
|
||||
float random (float x) {
|
||||
return fract(sin(x * 1243758.5453123));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
st *= 10.0;
|
||||
float i = floor(st.x);
|
||||
float f = fract(st.x);
|
||||
|
||||
// d直接等于随机函数返回值,这样d不连续
|
||||
float d = random(i);
|
||||
// float d = mix(random(i), random(i + 1.0), f);
|
||||
// float d = mix(random(i), random(i + 1.0), smoothstep(0.0, 1.0, f));
|
||||
// float d = mix(random(i), random(i + 1.0), f * f * (3.0 - 2.0 * f));
|
||||
|
||||
gl_FragColor.rgb = (smoothstep(st.y - 0.05, st.y, d) - smoothstep(st.y, st.y + 0.05, d)) * vec3(1.0);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,我们对floor(st.x)取随机数,取出10个不同的d值,然后把它们绘制出来,就能在画布上呈现出10段不连续的线段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/2c/2c89840e5d6e10ed22188bdc827b762c.jpeg" alt="">
|
||||
|
||||
然后,我们用 mix(random(i), random(i + 1.0), f); 替换 random(i)(你可以将上面代码第18行注释掉,将第19行注释去掉),那么这些线段的首尾就会连起来,也就是说我们将得到一段连续的折线。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/d4/aeccd8853f623190c30ed74759dfafd4.jpeg" alt="">
|
||||
|
||||
不过,我们得到的折线虽然连续,但因为这个函数在端点处不可导,所以它不平滑。因此,我们可以改用 mix(random(i), random(i + 1.0), smoothstep(0.0, 1.0, f)); 替换 random(i)(上面代码的第20行),或者直接采用三次多项式 mix(random(i), random(i + 1.0), f * f * (3.0 - 2.0 * f));(上面代码的第21行,这个三次多形式能达到和smoothstep一样的效果)来替换step。这样,我们就得到一条连续并且平滑的曲线了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/4a/f4a05f47b8520ec2bf0dff35d561244a.jpeg" alt="">
|
||||
|
||||
这也就是我们想要的噪声函数了。
|
||||
|
||||
但是,这个函数是一维的,如果要使用二维的,我们还可以把它扩展到二维。这个时候,我们就必须要知道,二维噪声和一维噪声之间的区别。很明显,一维噪声是对两点进行插值的,而二维噪声需要对平面画布上方形区域的四个顶点,分别从x、y方向进行两次插值。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/98/ef/9802a1a82dd52d108c1d5yy449cefbef.jpeg" alt="" title="图片来源:The Book of Shaders"> ](https://thebookofshaders.com/11/?lan=ch)
|
||||
|
||||
具体怎么做呢?我们可以把st与方形区域的四个顶点(对应四个向量)做插值,这样就能得到二维噪声。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
// 二维噪声,对st与方形区域的四个顶点插值
|
||||
highp float noise(vec2 st) {
|
||||
vec2 i = floor(st);
|
||||
vec2 f = fract(st);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix( mix( random( i + vec2(0.0,0.0) ),
|
||||
random( i + vec2(1.0,0.0) ), u.x),
|
||||
mix( random( i + vec2(0.0,1.0) ),
|
||||
random( i + vec2(1.0,1.0) ), u.x), u.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * 20.0;
|
||||
gl_FragColor.rgb = vec3(noise(st));
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过上面的代码,我们就可以得到下面这个看起来比较模糊的噪声图案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/b3/fb617c991c7abfa6b61fcb298ce4a8b3.jpeg" alt="" title="模糊的噪声图案">
|
||||
|
||||
## 噪声的应用
|
||||
|
||||
那你可能想问了,我们上面实现的一维噪声波形和二维的模糊噪声图案都比较简单,那它们到底是怎么模拟自然界中的现象,又该怎么实现有趣的视觉效果呢?
|
||||
|
||||
接下来,我们先结合上面得到的噪声函数,来讲2个简单的噪声应用,让你对它们能有更具体的认知。然后,我会在此基础上,再讲一些其他噪声函数,以及噪声能实现的更复杂视觉效果,让你对噪声有更深入的理解。
|
||||
|
||||
首先,我们可以结合噪声和距离场,来实现类似于水滴滚过物体表面的效果。
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = mix(vec2(-10, -10), vec2(10, 10), vUv);
|
||||
float d = distance(st, vec2(0));
|
||||
d *= noise(uTime + st);
|
||||
d = smoothstep(0.0, 1.0, d) - step(1.0, d);
|
||||
gl_FragColor.rgb = vec3(d);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/18/a6325bb4912957b864390789c5468118.gif" alt="">
|
||||
|
||||
我们也可以使用不同的距离场构造方式,加上旋转噪声,构造出类似于木头的条纹。代码如下:
|
||||
|
||||
```
|
||||
float lines(in vec2 pos, float b){
|
||||
float scale = 10.0;
|
||||
pos *= scale;
|
||||
return smoothstep(0.0, 0.5 + b * 0.5, abs((sin(pos.x * 3.1415) + b * 2.0)) * 0.5);
|
||||
}
|
||||
|
||||
vec2 rotate(vec2 v0, float ang) {
|
||||
float sinA = sin(ang);
|
||||
float cosA = cos(ang);
|
||||
mat3 m = mat3(cosA, -sinA, 0, sinA, cosA, 0, 0, 0, 1);
|
||||
return (m * vec3(v0, 1.0)).xy;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv.yx * vec2(10.0, 3.0);
|
||||
st = rotate(st, noise(st));
|
||||
|
||||
float d = lines(st, 0.5);
|
||||
|
||||
gl_FragColor.rgb = 1.0 - vec3(d);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/fe/e8237529e1aa3795e89ea3a9366a56fe.jpeg" alt="">
|
||||
|
||||
这两个应用的实现代码非常简单,你直接看代码就能理解。我更希望的是,你能通过我给出的代码,来理解这种噪声结合距离场的实现思路。
|
||||
|
||||
### 梯度噪声
|
||||
|
||||
我们前面说的噪声算法,它的原理是对离散的随机值进行插值,因此它又被称为**插值噪声**(Value Noise)。插值噪声有一个缺点,就是它的值的梯度不均匀。最直观的表现就是,二维噪声图像有明显的“块状”特点,不够平滑。
|
||||
|
||||
想要解决这个问题,我们可以使用另一种噪声算法,也就是**梯度噪声**(Gradient Noise)。梯度噪声是对随机的二维向量来插值,而不是一维的随机数。这样我们就能够获得更加平滑的噪声效果。梯度噪声的代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
vec2 random2(vec2 st){
|
||||
st = vec2( dot(st,vec2(127.1,311.7)),
|
||||
dot(st,vec2(269.5,183.3)) );
|
||||
return -1.0 + 2.0 * fract(sin(st) * 43758.5453123);
|
||||
}
|
||||
|
||||
// Gradient Noise by Inigo Quilez - iq/2013
|
||||
// https://www.shadertoy.com/view/XdXGW8
|
||||
float noise(vec2 st) {
|
||||
vec2 i = floor(st);
|
||||
vec2 f = fract(st);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
return mix( mix( dot( random2(i + vec2(0.0,0.0) ), f - vec2(0.0,0.0) ),
|
||||
dot( random2(i + vec2(1.0,0.0) ), f - vec2(1.0,0.0) ), u.x),
|
||||
mix( dot( random2(i + vec2(0.0,1.0) ), f - vec2(0.0,1.0) ),
|
||||
dot( random2(i + vec2(1.0,1.0) ), f - vec2(1.0,1.0) ), u.x), u.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * 20.0;
|
||||
gl_FragColor.rgb = vec3(0.5 * noise(st) + 0.5);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
梯度噪声可以平滑到什么程度呢?我在下面给出了两种噪声算法生成的图像,你可以明显得看出对比。第一个图是插值噪声的效果,黑白色之间过渡不够平滑,还有明显的色块感,第二个图是梯度噪声的效果,黑白的过渡就明显平滑多了,不再呈现块状。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/2d/f553f0eb5621f795a134e6d85478e52d.png" alt="" title="插值噪声与梯度噪声对比">
|
||||
|
||||
因此,梯度噪声在二维空间中的应用更广泛,许多有趣的模拟自然界特效的视觉实现都采用了梯度噪声。你可以研究一下[Shadertoy.com](https://www.shadertoy.com/)平台上的一些例子,其中很多模拟自然界的例子都和梯度噪声有关,我就不一一列举了。
|
||||
|
||||
### 用噪声实现云雾效果
|
||||
|
||||
我还想给你讲一种使用噪声来模拟云雾效果的方法。如果你看过极客时间里winter老师的《重学前端》,可能对这个方法有所了解,因为他在一篇加餐简单提到过。在这里,我想给你详细说说云雾效果究竟是怎么实现的。
|
||||
|
||||
我们可以通过改变噪声范围,然后按照不同权重来叠加的方式创造云雾效果。比如,我们可以将噪声叠加6次,然后让它每次叠加的时候范围扩大一倍,但是权重减半。通过这个新的噪声算法,我们就能生成云雾效果了。你也可以试试,让这个噪声配合色相变化,可以创造出非常有趣的图形,比如模拟飞机航拍效果。
|
||||
|
||||
```
|
||||
//
|
||||
|
||||
#define OCTAVES 6
|
||||
float mist(vec2 st) {
|
||||
//Initial values
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
|
||||
// 叠加6次
|
||||
for(int i = 0; i < OCTAVES; i++) {
|
||||
// 每次范围扩大一倍,权重减半
|
||||
value += amplitude * noise(st);
|
||||
st *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
//配合色相的变化
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv;
|
||||
st.x += 0.1 * uTime;
|
||||
gl_FragColor.rgb = hsb2rgb(vec3 (mist(st), 1.0, 1.0));
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/f2/3f1719cb63f5e6d0c0d182a15505e5f2.gif" alt="" title="使用云雾效果生成的图案,非常像是飞机航拍">
|
||||
|
||||
### Simplex Noise
|
||||
|
||||
接下来,我还想给你讲一种更新的噪声算法,它是Ken Perlin在2001 年的 Siggraph会议上展示的Simplex Noise算法。
|
||||
|
||||
相比于前面的噪声算法,Simplex Noise算法有更低的计算复杂度和更少的乘法运算,并且可以用更少的计算量达到更高的维度,而且它制造出的噪声非常自然。
|
||||
|
||||
Simplex Noise与插值噪声以及梯度噪声的不同之处在于,它不是对四边形进行插值,而是对三角网格进行插值。与四边形插值相比,三角网格插值需要计算的点更少了,这样自然大大降低了计算量,从而提升了渲染性能。
|
||||
|
||||
Simplex Noise具体的实现思路非常精巧和复杂,其中包含的数学技巧比较高深,这里我就不详细来讲了,如果你有兴趣学习可以参考[Book of Shaders的文章](https://thebookofshaders.com/11/?lan=ch)来学习。
|
||||
|
||||
尽管Simplex Noise的原理很巧妙和复杂,但是在Shader中实现Simplex Noise代码并不算太复杂,你可以记住下面的代码,在需要的时候直接拿来使用。
|
||||
|
||||
```
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
|
||||
|
||||
//
|
||||
// Description : GLSL 2D simplex noise function
|
||||
// Author : Ian McEwan, Ashima Arts
|
||||
// Maintainer : ijm
|
||||
// Lastmod : 20110822 (ijm)
|
||||
// License :
|
||||
// Copyright (C) 2011 Ashima Arts. All rights reserved.
|
||||
// Distributed under the MIT License. See LICENSE file.
|
||||
// https://github.com/ashima/webgl-noise
|
||||
//
|
||||
float noise(vec2 v) {
|
||||
// Precompute values for skewed triangular grid
|
||||
const vec4 C = vec4(0.211324865405187,
|
||||
// (3.0-sqrt(3.0))/6.0
|
||||
0.366025403784439,
|
||||
// 0.5*(sqrt(3.0)-1.0)
|
||||
-0.577350269189626,
|
||||
// -1.0 + 2.0 * C.x
|
||||
0.024390243902439);
|
||||
// 1.0 / 41.0
|
||||
|
||||
// First corner (x0)
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
|
||||
// Other two corners (x1, x2)
|
||||
vec2 i1 = vec2(0.0);
|
||||
i1 = (x0.x > x0.y)? vec2(1.0, 0.0):vec2(0.0, 1.0);
|
||||
vec2 x1 = x0.xy + C.xx - i1;
|
||||
vec2 x2 = x0.xy + C.zz;
|
||||
|
||||
// Do some permutations to avoid
|
||||
// truncation effects in permutation
|
||||
i = mod289(i);
|
||||
vec3 p = permute(
|
||||
permute( i.y + vec3(0.0, i1.y, 1.0))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0 ));
|
||||
|
||||
vec3 m = max(0.5 - vec3(
|
||||
dot(x0,x0),
|
||||
dot(x1,x1),
|
||||
dot(x2,x2)
|
||||
), 0.0);
|
||||
|
||||
m = m*m ;
|
||||
m = m*m ;
|
||||
|
||||
// Gradients:
|
||||
// 41 pts uniformly over a line, mapped onto a diamond
|
||||
// The ring size 17*17 = 289 is close to a multiple
|
||||
// of 41 (41*7 = 287)
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
|
||||
// Normalise gradients implicitly by scaling m
|
||||
// Approximation of: m *= inversesqrt(a0*a0 + h*h);
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0+h*h);
|
||||
|
||||
// Compute final noise value at P
|
||||
vec3 g = vec3(0.0);
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * vec2(x1.x,x2.x) + h.yz * vec2(x1.y,x2.y);
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * 20.0;
|
||||
gl_FragColor.rgb = vec3(0.5 * noise(st) + 0.5);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
渲染效果如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/0a/3984a318acc90ccce0dcaf65aaf0a60a.jpeg" alt="">
|
||||
|
||||
Simplex Noise可以实现出令人惊叹的效果,在[Shadertoy.com](https://www.shadertoy.com/)平台上经常有大神分享他们创作的神奇效果。比如,[这个](https://www.shadertoy.com/view/MdSXzz)像某种溶洞的岩壁效果,就有一种大自然鬼斧神工的韵味在。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/48/bbb7f35c9a0f4b639825074764025a48.gif" alt="">
|
||||
|
||||
再比如,[这种像电影大片中才有的效果](https://www.shadertoy.com/view/Ms2SD1),你很难想象这并不是视频,甚至不是图片,只不过是我们用数学公式在Shader中计算并绘制出来的图案而已。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/cc/0c209d3f0f65d45457420f74c057d2cc.gif" alt="">
|
||||
|
||||
### 网格噪声
|
||||
|
||||
最后,我们来讲讲网格噪声。前面我们已经使用过大量网格化的技术,我想你也应该比较熟悉了。那什么是网格噪声呢?它就是将噪声与网格结合使用的一种纹理生成技术。下面,让我们通过一个生成动态生物细胞的例子,来详细理解一下如何使用网格噪声。
|
||||
|
||||
首先,我们用网格技术将画布分为10*10的网格。然后,我们构建距离场。这个距离场是在每个网格中随机一个特征点,然后计算网格内到该点的距离,最后根据距离来着色。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
|
||||
vec2 random2(vec2 st){
|
||||
st = vec2( dot(st,vec2(127.1,311.7)),
|
||||
dot(st,vec2(269.5,183.3)) );
|
||||
return fract(sin(st) * 43758.5453123);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv * 10.0;
|
||||
|
||||
float d = 1.0;
|
||||
vec2 i_st = floor(st);
|
||||
vec2 f_st = fract(st);
|
||||
|
||||
vec2 p = random2(i_st);
|
||||
d = distance(f_st, p);
|
||||
gl_FragColor.rgb = vec3(d);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过上面的代码,我们最终能得到如下的效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/ed/dc089df759336b3636f2a3cf7bfa71ed.jpeg" alt="">
|
||||
|
||||
我们可以看到,这里的每个网格是独立的,并且界限分明。那如果我们想让它们的边界过渡更圆滑该怎么办呢?我们可以在原来的代码上做改变,具体来说就是不仅计算特征点到当前网格的距离,还要计算它到周围相邻的8个网格的距离,然后取最小值。与其他的编程语言类似,这个可以通过for循环来实现:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = vUv * 10.0;
|
||||
float d = 1.0;
|
||||
vec2 i_st = floor(st);
|
||||
vec2 f_st = fract(st);
|
||||
|
||||
for(int i = -1; i <= 1; i++) {
|
||||
for(int j = -1; j <= 1; j++) {
|
||||
vec2 neighbor = vec2(float(i), float(j));
|
||||
vec2 p = random2(i_st + neighbor);
|
||||
d = min(d, distance(f_st, neighbor + p));
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor.rgb = vec3(d);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里有一点需要注意,GLSL语言的for循环限制比较多。其中,检查循环是否继续的次数必须是常量,不能是变量。所以GLSL中没有动态循环,而且迭代的次数必须是确定的。这里我们要检查9个网格,所以就用了两重循环来实现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/c6/e8ed59accc7575f2fa22dc0a3e580fc6.jpeg" alt="" title="计算像素坐标到九个特征点的最短距离">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/bb/277a3bb605dc035edb2ae43db1a679bb.jpeg" alt="">
|
||||
|
||||
然后我们加上uTime,让网格动起来,另外我们把特征点也给显示出来。我们修改一下代码:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec2 st = vUv * 10.0;
|
||||
|
||||
float d = 1.0;
|
||||
vec2 i_st = floor(st);
|
||||
vec2 f_st = fract(st);
|
||||
|
||||
for(int i = -1; i <= 1; i++) {
|
||||
for(int j = -1; j <= 1; j++) {
|
||||
vec2 neighbor = vec2(float(i), float(j));
|
||||
vec2 p = random2(i_st + neighbor);
|
||||
p = 0.5 + 0.5 * sin(uTime + 6.2831 * p);
|
||||
d = min(d, distance(f_st, neighbor + p));
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor.rgb = vec3(d) + step(d, 0.03);
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,最终绘制的效果如下,它就有点像是运动的生物细胞。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/c6/b635854be3e0a9336906b02e46cdb3c6.gif" alt="">
|
||||
|
||||
网格噪声是一种目前被广泛应用的程序化纹理技术,用来生成随机网格类的视觉效果,可以用来模拟物体表面的晶格、晶体生长、细胞、微生物等等有趣的效果。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/63/c7/6396d5c7f143410352cb04da2a3cdbc7.jpg" alt="" title="网格噪声模拟物体表面晶格,图片来源于The Book of Shaders">](https://thebookofshaders.com/12/?lan=ch)
|
||||
|
||||
## 要点总结
|
||||
|
||||
总的来说,这节课我给你讲的技术,实际上是一种复杂的程序化纹理生成技术。所谓程序化纹理生成技术,就是用程序来生成物体表面的图案。我们在这些图案中引入类似于自然界中的随机性,就可以模拟出自然的、丰富多采的以及包含真实细节的纹理图案。
|
||||
|
||||
这其中最有代表性的就是噪声了,噪声就是随机性与连续性结合而成的。噪声是自然界中普遍存在的自然规律。模拟噪声的基本思路是对离散的随机数进行平滑处理,对随机数进行平滑处理有不同的数学技巧,所以有插值噪声、梯度噪声、Simplex Noise等等不同的噪声算法。它们各有特点,我们可以根据不同的情况来选择怎么使用。
|
||||
|
||||
这一节课的内容偏向于技巧性,要想掌握好,我建议你多动手实践。我推荐给你一个非常不错的平台,[Shadertoy.com](https://www.shadertoy.com/) 。它是一个非常优秀的创作和分享着色器效果的平台,你可以在上面学习到很多优秀的案例,然后通过代码来理解创作者的创意和思路,巩固今天所学的知识。
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
你能试着写出一个Shader,来实现我在下面给出的网格噪声效果吗?欢迎你把它分享出来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/a0/d098bfc78426b56bf83efd5ddae6ffa0.gif" alt="">
|
||||
|
||||
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
|
||||
|
||||
## 源码
|
||||
|
||||
完整示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/noise)
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
[1] [Shadertoy](https://www.shadertoy.com/)<br>
|
||||
[2] [The Book of Shaders](https://thebookofshaders.com/11/?lan=ch)
|
||||
654
极客时间专栏/跟月影学可视化/视觉基础篇/17 | 如何使用后期处理通道增强图像效果?.md
Normal file
654
极客时间专栏/跟月影学可视化/视觉基础篇/17 | 如何使用后期处理通道增强图像效果?.md
Normal file
@@ -0,0 +1,654 @@
|
||||
<audio id="audio" title="17 | 如何使用后期处理通道增强图像效果?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/23/169424ecc77cb01d8e70cc3108829e23.mp3"></audio>
|
||||
|
||||
你好,我是月影。
|
||||
|
||||
前面几节课,我们学习了利用向量和矩阵公式,来处理像素和生成纹理的技巧,但是这些技巧都有一定的局限性:每个像素是彼此独立的,不能共享信息。
|
||||
|
||||
为什么这么说呢?因为GPU是并行渲染的,所以在着色器的执行中,每个像素的着色都是同时进行的。这样一来,我们就不能获得某一个像素坐标周围坐标点的颜色信息,也不能获得要渲染图像的全局信息。
|
||||
|
||||
这会导致什么问题呢?如果我们要实现与周围像素点联动的效果,比如给生成的纹理添加平滑效果滤镜,就不能直接通过着色器的运算来实现了。
|
||||
|
||||
因此,在WebGL中,像这样不能直接通过着色器运算来实现的效果,我们需要使用其他的办法来实现,其中一种办法就是使用**后期处理通道**。所谓后期处理通道,是指将渲染出来的图像作为纹理输入给新着色器处理,是一种二次加工的手段。这么一来,虽然我们不能从当前渲染中获取周围的像素信息,却可以从纹理中获取任意uv坐标下的像素信息,也就相当于可以获取任意位置的像素信息了。
|
||||
|
||||
使用后期处理通道的一般过程是,我们先正常地将数据送入缓冲区,然后执行WebGLProgram。只不过,在执行了WebGLProgram之后,我们要将输出的结果再作为纹理,送入另一个WebGLProgram进行处理,这个过程可以进行一次,也可以循环多次。最后,经过两次WebGLProgram处理之后,我们再输出结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/75/a9b5a5f90e2c3e465a0a6d2b7070ef75.jpg" alt="" title="后期通道处理的一般过程示意图">
|
||||
|
||||
你可以先仔细看看这张流程总结图,加深一下印象。接下来,我会结合这个过程,说说怎么用后期处理通道,来实现Blur滤镜、辉光效果和烟雾效果,这样你就能理解得更深刻了。
|
||||
|
||||
首先,我们来实现Blur滤镜。
|
||||
|
||||
## 如何用后期处理通道实现Blur滤镜?
|
||||
|
||||
其实在第11节课中,我们已经在Canvas2D中实现了Bblur滤镜(高斯模糊的平滑效果滤镜),但Canvas2D实现滤镜的性能不佳,尤其是在图片较大,需要大量计算的时候。
|
||||
|
||||
而在WebGL中,我们可以通过后期处理来实现高性能的Blur滤镜。下面,我就以给随机三角形图案加Blur滤镜为例,来说说具体的操作。
|
||||
|
||||
首先,我们实现一个绘制随机三角形图案的着色器。代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
float line_distance(in vec2 st, in vec2 a, in vec2 b) {
|
||||
vec3 ab = vec3(b - a, 0);
|
||||
vec3 p = vec3(st - a, 0);
|
||||
float l = length(ab);
|
||||
return cross(p, normalize(ab)).z;
|
||||
}
|
||||
|
||||
float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
|
||||
vec3 ab = vec3(b - a, 0);
|
||||
vec3 p = vec3(st - a, 0);
|
||||
float l = length(ab);
|
||||
float d = abs(cross(p, normalize(ab)).z);
|
||||
float proj = dot(p, ab) / l;
|
||||
if(proj >= 0.0 && proj <= l) return d;
|
||||
return min(distance(st, a), distance(st, b));
|
||||
}
|
||||
|
||||
float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
|
||||
float d1 = line_distance(st, a, b);
|
||||
float d2 = line_distance(st, b, c);
|
||||
float d3 = line_distance(st, c, a);
|
||||
|
||||
if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
|
||||
return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
|
||||
}
|
||||
|
||||
return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
|
||||
}
|
||||
|
||||
float random (vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
|
||||
vec3 hsb2rgb(vec3 c){
|
||||
vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
|
||||
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
|
||||
return c.z * mix(vec3(1.0), rgb, c.y);
|
||||
}
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv;
|
||||
st *= 10.0;
|
||||
vec2 i_st = floor(st);
|
||||
vec2 f_st = 2.0 * fract(st) - vec2(1);
|
||||
float r = random(i_st);
|
||||
float sign = 2.0 * step(0.5, r) - 1.0;
|
||||
|
||||
float d = triangle_distance(f_st, vec2(-1), vec2(1), sign * vec2(1, -1));
|
||||
gl_FragColor.rgb = (smoothstep(-0.85, -0.8, d) - smoothstep(0.0, 0.05, d)) * hsb2rgb(vec3(r + 1.2, 0.5, r));
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个着色器绘制出的效果如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/5b/bce2b0fbe3954c9a690a46f422de4f5b.jpeg" alt="">
|
||||
|
||||
接着就是重点了,我们要使用后期处理通道对它进行高斯模糊。
|
||||
|
||||
首先,我们需要准备另一个着色器:blurFragment。通过它,我们能将第一次渲染后生成的纹理tMap内容给显示出来。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D tMap;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
gl_FragColor.rgb = color.rgb;
|
||||
gl_FragColor.a = color.a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们要修改JavaScript代码,把渲染分为两次。第一次渲染时,我们启用program程序,但不直接把图形输出到画布上,而是输出到一个帧缓冲对象(Frame Buffer Object)上。第二次渲染时,我们再启用blurProgram程序,将第一次渲染完成的纹理(fbo.texture)作为blurFragment的tMap变量,这次的输出绘制到画布上。代码如下:
|
||||
|
||||
```
|
||||
...
|
||||
|
||||
renderer.useProgram(program);
|
||||
|
||||
renderer.setMeshData([{
|
||||
positions: [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
],
|
||||
attributes: {
|
||||
uv: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
],
|
||||
},
|
||||
cells: [[0, 1, 2], [2, 0, 3]],
|
||||
}]);
|
||||
|
||||
const fbo = renderer.createFBO();
|
||||
renderer.bindFBO(fbo);
|
||||
renderer.render();
|
||||
renderer.bindFBO(null);
|
||||
|
||||
const blurProgram = renderer.compileSync(blurFragment, vertex);
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.setMeshData(program.meshData);
|
||||
renderer.uniforms.tMap = fbo.texture;
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
其中,renderer.createFBO是创建帧缓冲对象,bindFBO是绑定帧缓冲对象。为了方便调用,我在这里通过gl-renderer做了一层简单的封装。
|
||||
|
||||
那经过两次渲染之后,我们运行程序输出的结果和之前输出的并不会有什么区别。因为第二次渲染只不过是将第一次渲染到帧缓冲的结果原封不动地输出到画布上了。
|
||||
|
||||
接下来,我们修改blurFragment的代码,在其中添加高斯模糊的代码。代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D tMap;
|
||||
uniform int axis;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
|
||||
// 高斯矩阵的权重值
|
||||
float weight[5];
|
||||
weight[0] = 0.227027;
|
||||
weight[1] = 0.1945946;
|
||||
weight[2] = 0.1216216;
|
||||
weight[3] = 0.054054;
|
||||
weight[4] = 0.016216;
|
||||
|
||||
// 每一个相邻像素的坐标间隔,这里的512可以用实际的Canvas像素宽代替
|
||||
float tex_offset = 1.0 / 512.0;
|
||||
|
||||
vec3 result = color.rgb;
|
||||
result *= weight[0];
|
||||
for(int i = 1; i < 5; ++i) {
|
||||
float f = float(i);
|
||||
if(axis == 0) { // x轴的高斯模糊
|
||||
result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
|
||||
result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
|
||||
} else { // y轴的高斯模糊
|
||||
result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
|
||||
result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor.rgb = result.rgb;
|
||||
gl_FragColor.a = color.a;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
因为高斯模糊有两个方向,x和y方向,所以我们至少要执行两次渲染,一次对x轴,另一次对y轴。如果想要达到更好的效果,我们还可以执行多次渲染。
|
||||
|
||||
那我们就以分别对x轴和y轴执行2次渲染为例,修改后的JavaScript代码如下:
|
||||
|
||||
```
|
||||
// 创建两个FBO对象交替使用
|
||||
const fbo1 = renderer.createFBO();
|
||||
const fbo2 = renderer.createFBO();
|
||||
|
||||
// 第一次,渲染原始图形
|
||||
renderer.bindFBO(fbo1);
|
||||
renderer.render();
|
||||
|
||||
// 第二次,对x轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.setMeshData(program.meshData);
|
||||
renderer.bindFBO(fbo2);
|
||||
renderer.uniforms.tMap = fbo1.texture;
|
||||
renderer.uniforms.axis = 0;
|
||||
renderer.render();
|
||||
|
||||
// 第三次,对y轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.bindFBO(fbo1);
|
||||
renderer.uniforms.tMap = fbo2.texture;
|
||||
renderer.uniforms.axis = 1;
|
||||
renderer.render();
|
||||
|
||||
// 第四次,对x轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.bindFBO(fbo2);
|
||||
renderer.uniforms.tMap = fbo1.texture;
|
||||
renderer.uniforms.axis = 0;
|
||||
renderer.render();
|
||||
|
||||
// 第五次,对y轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.bindFBO(null);
|
||||
renderer.uniforms.tMap = fbo2.texture;
|
||||
renderer.uniforms.axis = 1;
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,我们创建了两个FBO对象,然后将它们交替使用。我们一共进行5次绘制,先对原始图片执行1次渲染,再进行4次后期处理。
|
||||
|
||||
这里啊,我还要告诉你一个小技巧。本来啊,在执行的这5次绘制中,前四次都是输出到帧缓冲对象,所以我们至少需要4个FBO对象。但是,由于我们可以交替使用FBO对象,也就是可以把用过的对象重复使用。因此,无论需要绘制多少次,我们都只要创建两个对象就可以,也就节约了内存。
|
||||
|
||||
最终,我们就能通过后期处理通道实现Blur滤镜,给三角形图案加上模糊的效果了。渲染结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/91/0d14fea4328158fe9d32f09181a22f91.jpeg" alt="">
|
||||
|
||||
## 如何用后期处理通道实现辉光效果?
|
||||
|
||||
在上面这个例子中,我们是对所有元素进行高斯模糊的。那除此之外,我们还可以对特定元素进行高斯模糊。在可视化和游戏开发中,就常用这种技巧来实现元素的“辉光”效果。比如,下面这张图就是用辉光效果实现的浩瀚宇宙背景。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/e9/4a/e9b5daeefdde2111cb955d2b91986b4a.jpeg" alt="" title="全局辉光效果(图片来源于知乎:H光大小姐)">](https://zhuanlan.zhihu.com/p/44131797)
|
||||
|
||||
那类似这样的辉光效果该怎么实现呢?我们可以在前面添加高斯模糊例子的基础上进行修改。首先,我们给blurFragment加了一个关于亮度的滤镜,将颜色亮度大于filter值的三角形过滤出来添加高斯模糊。修改后的代码如下。
|
||||
|
||||
```
|
||||
uniform float filter;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
brightness = step(filter, brightness);
|
||||
|
||||
// 高斯矩阵的权重值
|
||||
float weight[5];
|
||||
weight[0] = 0.227027;
|
||||
weight[1] = 0.1945946;
|
||||
weight[2] = 0.1216216;
|
||||
weight[3] = 0.054054;
|
||||
weight[4] = 0.016216;
|
||||
|
||||
// 每一个相邻像素的坐标间隔,这里的512可以用实际的Canvas像素宽代替
|
||||
float tex_offset = 1.0 / 512.0;
|
||||
|
||||
vec3 result = color.rgb;
|
||||
result *= weight[0];
|
||||
for(int i = 1; i < 5; ++i) {
|
||||
float f = float(i);
|
||||
if(axis == 0) { // x轴的高斯模糊
|
||||
result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
|
||||
result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
|
||||
} else { // y轴的高斯模糊
|
||||
result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
|
||||
result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor.rgb = brightness * result.rgb;
|
||||
gl_FragColor.a = color.a;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
然后,我们再增加一个bloomFragment着色器,用来做最后的效果混合。这里,我们会用到一个叫做[**Tone Mapping**](https://zh.wikipedia.org/wiki/%E8%89%B2%E8%B0%83%E6%98%A0%E5%B0%84)(色调映射)的方法。这个方法就比较复杂了,在这里,你只要知道它可以将对比度过大的图像色调映射到合理的范围内就可以了,其他的内容你可以在课后看一下我给出的参考链接。
|
||||
|
||||
这个着色器的代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform sampler2D tSource;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec3 color = texture2D(tSource, vUv).rgb;
|
||||
vec3 bloomColor = texture2D(tMap, vUv).rgb;
|
||||
color += bloomColor;
|
||||
// tone mapping
|
||||
float exposure = 2.0;
|
||||
float gamma = 1.3;
|
||||
vec3 result = vec3(1.0) - exp(-color * exposure);
|
||||
// also gamma correct while we're at it
|
||||
if(length(bloomColor) > 0.0) {
|
||||
result = pow(result, vec3(1.0 / gamma));
|
||||
}
|
||||
gl_FragColor.rgb = result;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,我们修改JavaScript渲染的逻辑,添加新的后期处理规则。这里,我们要使用三个FBO对象,因为第一个FBO对象在渲染原始图形之后,还要在混合效果时使用,后两个对象是用来交替使用完成高斯模糊的。最后,我们再将原始图形和高斯模糊的结果进行效果混合就可以了。修改后的代码如下:
|
||||
|
||||
```
|
||||
// 创建三个FBO对象,fbo1和fbo2交替使用
|
||||
const fbo0 = renderer.createFBO();
|
||||
const fbo1 = renderer.createFBO();
|
||||
const fbo2 = renderer.createFBO();
|
||||
|
||||
// 第一次,渲染原始图形
|
||||
renderer.bindFBO(fbo0);
|
||||
renderer.render();
|
||||
|
||||
// 第二次,对x轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.setMeshData(program.meshData);
|
||||
renderer.bindFBO(fbo2);
|
||||
renderer.uniforms.tMap = fbo0.texture;
|
||||
renderer.uniforms.axis = 0;
|
||||
renderer.uniforms.filter = 0.7;
|
||||
renderer.render();
|
||||
|
||||
// 第三次,对y轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.bindFBO(fbo1);
|
||||
renderer.uniforms.tMap = fbo2.texture;
|
||||
renderer.uniforms.axis = 1;
|
||||
renderer.uniforms.filter = 0;
|
||||
renderer.render();
|
||||
|
||||
// 第四次,对x轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.bindFBO(fbo2);
|
||||
renderer.uniforms.tMap = .texture;
|
||||
renderer.uniforms.axis = 0;
|
||||
renderer.uniforms.filter = 0;
|
||||
renderer.render();
|
||||
|
||||
// 第五次,对y轴高斯模糊
|
||||
renderer.useProgram(blurProgram);
|
||||
renderer.bindFBO(fbo1);
|
||||
renderer.uniforms.tMap = fbo2.texture;
|
||||
renderer.uniforms.axis = 1;
|
||||
renderer.uniforms.filter = 0;
|
||||
renderer.render();
|
||||
|
||||
// 第六次,叠加辉光
|
||||
renderer.useProgram(bloomProgram);
|
||||
renderer.setMeshData(program.meshData);
|
||||
renderer.bindFBO(null);
|
||||
renderer.uniforms.tSource = fbo0.texture;
|
||||
renderer.uniforms.tMap = fbo1.texture;
|
||||
renderer.uniforms.axis = 1;
|
||||
renderer.uniforms.filter = 0;
|
||||
renderer.render();
|
||||
|
||||
```
|
||||
|
||||
这样渲染之后,我就能让三角形图案中几个比较亮的三角形,产生一种微微发光的效果。渲染效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/c5/6905c7b0ff6b8b8d903e0eaeefcbfbc5.jpeg" alt="" title="局部辉光效果示意图">
|
||||
|
||||
这样,我们就实现了最终的局部辉光效果。实现它的关键,就是在高斯模糊原理的基础上,将局部高斯模糊的图像与原始图像叠加。
|
||||
|
||||
## 如何用后期处理通道实现烟雾效果?
|
||||
|
||||
除了模糊和辉光效果之外,后期处理通道还经常用来实现烟雾效果。接下来,我们就实现一个小圆的烟雾效果。具体的实现过程主要分为两步:第一步和前面两个例子一样,我们通过创建一个shader,画出一个简单的圆。第二步,我们对这个圆进行后期处理,不过这次的处理方法就和实现辉光不同了。
|
||||
|
||||
下面,我们就一起来看。
|
||||
|
||||
首先,我们创建一个简单的shader,也就是使用距离场在画布上画一个圆。这个shader我们非常熟悉,具体的代码如下:
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
float d = length(st);
|
||||
gl_FragColor.rgb = vec3(1.0 - smoothstep(0.05, 0.055, d));
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/77/72d1816d29d1401636e0099016f80477.jpeg" alt="">
|
||||
|
||||
接着,我们修改一下shader代码,增加uTime、tMap这两个变量,代码如下所示。其中,uTime用来控制图像随时间变化,而tMap是我们用来做后期处理的变量。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D tMap;
|
||||
uniform float uTime;
|
||||
|
||||
void main() {
|
||||
vec3 smoke = vec3(0);
|
||||
if(uTime <= 0.0) {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
float d = length(st);
|
||||
smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
|
||||
}
|
||||
vec3 diffuse = texture2D(tMap, vUv).rgb;
|
||||
gl_FragColor.rgb = diffuse + smoke;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们依然创建两个FBO,用它们交替进行绘制。最后,我们把绘制的内容输出到画布上。这里,我使用了一个if语句,根据绘制过程判断初始绘制还是后续的叠加过程,就能把着色器合并成一个。这样一来,不管是输出到画布还是FBO,我们使用同一个program就可以了。
|
||||
|
||||
```
|
||||
const fbo = {
|
||||
readFBO: renderer.createFBO(),
|
||||
writeFBO: renderer.createFBO(),
|
||||
get texture() {
|
||||
return this.readFBO.texture;
|
||||
},
|
||||
swap() {
|
||||
const tmp = this.writeFBO;
|
||||
this.writeFBO = this.readFBO;
|
||||
this.readFBO = tmp;
|
||||
},
|
||||
};
|
||||
|
||||
function update(t) {
|
||||
// 输出到画布
|
||||
renderer.bindFBO(null);
|
||||
renderer.uniforms.uTime = t / 1000;
|
||||
renderer.uniforms.tMap = fbo.texture;
|
||||
renderer.render();
|
||||
// 同时输出到FBO
|
||||
renderer.bindFBO(fbo.writeFBO);
|
||||
renderer.uniforms.tMap = fbo.texture;
|
||||
// 交换读写缓冲以便下一次写入
|
||||
fbo.swap();
|
||||
renderer.render();
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
update(0);
|
||||
|
||||
```
|
||||
|
||||
你会发现,上面的代码执行以后输出的画面并没有什么变化。这是为什么呢?因为我们第一次渲染时,也就是当uTime为0的时候,我们直接画了一个圆。而当我们从上一次绘制的纹理中获取信息,重新渲染时,因为每次获取的纹理图案都是不变的,所以现在的画面依然是静止的圆。
|
||||
|
||||
如果我们想让这个图动起来,比如说让它向上升,那么我们只要在每次绘制的时候,改变一下采样的y坐标,就是每次从tMap取样时取当前纹理坐标稍微下方一点的像素点就可以了。具体的操作代码如下:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec3 smoke = vec3(0);
|
||||
if(uTime <= 0.0) {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
float d = length(st);
|
||||
smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
|
||||
}
|
||||
vec2 st = vUv;
|
||||
st.y -= 0.001;
|
||||
vec3 diffuse = texture2D(tMap, st).rgb;
|
||||
gl_FragColor.rgb = diffuse + smoke;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/44/ec596ee38eabbbyy06061fe638a3df44.gif" alt="">
|
||||
|
||||
不过,由于纹理采样精度的问题,我们得到的上升圆还会有一个扩散的效果。不过这没有关系,它不影响我们接下来要实现的烟雾效果。
|
||||
|
||||
接下来,我们需要构建一个烟雾的扩散模型,也就是以某个像素位置以及周边像素的纹理颜色来计算新的颜色值。为了方便你理解,我就以一个5*5的画布为例来详细说说。假设,这个画布只有中心五个位置的颜色是纯白(1.0),周围都是黑色,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/6b/8ed93106a13428492bd5bc2eyy8d676b.jpeg" alt="">
|
||||
|
||||
在这个扩散模型中,每个格子到下一时刻的颜色变化量,等于它周围四个格子的颜色值之和减去它自身颜色值的4倍,乘以扩散系数。
|
||||
|
||||
假设扩散系数是常量0.1,那么第一轮每一格的颜色值我在表格上标出来了,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/44/6f46f3354b470d217110764cfc3f0344.jpg" alt="">
|
||||
|
||||
可以看到,上图中有三种颜色的格子,分别是红色、蓝色和绿色。下面,我们直接来看颜色值的计算过程。
|
||||
|
||||
首先是中间红色的那个格子。因为它四周的格子颜色都是1.0,所以它的颜色变化量是:0.1 * ((1.0 + 1.0 + 1.0 + 1.0) - 4 * 1.0) = 0,那么下一帧的颜色值还是1.0不变。
|
||||
|
||||
其次,红格子周围的四个蓝色格子。它们下一帧的颜色变化量为:0.1 * ((1.0 + 0 + 0 + 0)- 4 * 1.0) = -0.3,那么它们下一帧的颜色值都要减去0.3 就是 0.7。
|
||||
|
||||
最后,在计算绿色格子下一帧的颜色值时,要分为两种情况。
|
||||
|
||||
第一种,当要计算的绿色格子和两个蓝色格子相邻的时候,颜色变化量为:0.1 * ((1.0 + 1.0 + 0 + 0) - 4 * 0) = 0.2,所以绿格子下一帧的颜色值变为0.2。
|
||||
|
||||
第二种,当这个绿色格子只和一个蓝色格子相邻的时候,颜色变化量为0.1,那么绿格子下一帧的颜色值就变为0.1。
|
||||
|
||||
就这样,我们把每一帧颜色按照这个规则不断迭代下去,就能得到一个烟雾扩散效果了。那我们下一步就是把它实现到Shader中,不过,在Fragment Shader中添加扩散模型的时候,为了让这个烟雾效果,能上升得更明显,我稍稍修改了一下扩散公式的权重,让它向上的幅度比较大。
|
||||
|
||||
```
|
||||
#ifdef GL_ES
|
||||
precision highp float;
|
||||
#endif
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D tMap;
|
||||
uniform float uTime;
|
||||
|
||||
void main() {
|
||||
vec3 smoke = vec3(0);
|
||||
if(uTime <= 0.0) {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
float d = length(st);
|
||||
smoke = vec3(step(d, 0.05));
|
||||
// smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
|
||||
}
|
||||
|
||||
vec2 st = vUv;
|
||||
|
||||
float offset = 1.0 / 256.0;
|
||||
vec3 diffuse = texture2D(tMap, st).rgb;
|
||||
|
||||
vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
|
||||
vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
|
||||
vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
|
||||
vec4 down = texture2D(tMap, st + vec2(0.0, offset));
|
||||
|
||||
float diff = 8.0 * 0.016 * (
|
||||
left.r +
|
||||
right.r +
|
||||
down.r +
|
||||
2.0 * up.r -
|
||||
5.0 * diffuse.r
|
||||
);
|
||||
|
||||
gl_FragColor.rgb = (diffuse + diff) + smoke;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/27/816c8798dbe1bd0999c225fa068d8e27.gif" alt="">
|
||||
|
||||
你会发现,这个效果还不是特别真实。那为了达到更真实的烟雾效果,我们还可以在扩散函数上增加一些噪声,代码如下:
|
||||
|
||||
```
|
||||
void main() {
|
||||
vec3 smoke = vec3(0);
|
||||
if(uTime <= 0.0) {
|
||||
vec2 st = vUv - vec2(0.5);
|
||||
float d = length(st);
|
||||
smoke = vec3(step(d, 0.05));
|
||||
// smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
|
||||
}
|
||||
|
||||
vec2 st = vUv;
|
||||
|
||||
float offset = 1.0 / 256.0;
|
||||
vec3 diffuse = texture2D(tMap, st).rgb;
|
||||
|
||||
vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
|
||||
vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
|
||||
vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
|
||||
vec4 down = texture2D(tMap, st + vec2(0.0, offset));
|
||||
|
||||
float rand = noise(st + 5.0 * uTime);
|
||||
float diff = 8.0 * 0.016 * (
|
||||
(1.0 + rand) * left.r +
|
||||
(1.0 - rand) * right.r +
|
||||
down.r +
|
||||
2.0 * up.r -
|
||||
5.0 * diffuse.r
|
||||
);
|
||||
|
||||
gl_FragColor.rgb = (diffuse + diff) + smoke;
|
||||
gl_FragColor.a = 1.0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,我们最终实现的效果看起来就会更真实一些:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/1c/aa617ae27b63fc4687279a17298a721c.gif" alt="">
|
||||
|
||||
## 要点总结
|
||||
|
||||
今天,我们学习了怎么在WebGL中,使用后期处理通道来增强图像的视觉效果。我们讲了两个比较常用的效果,一个是Blur滤镜以及基于它实现辉光效果,另一个是烟雾效果。
|
||||
|
||||
那后期处理通道实现这些效果的核心原理其实都是一样的,都是把第一次渲染后的内容输出到帧缓冲对象FBO中,然后把这个对象的内容作为纹理图片,再进行下一次渲染,这个渲染的过程可以重复若干次。最后,我们再把结果输出到屏幕上,就可以了。
|
||||
|
||||
到这里,视觉基础篇的内容,我们就全部讲完了。在这个模块里,我们围绕处理图像的细节,系统地学习了怎么表示颜色,怎么生成重复图案,怎么构造和使用滤镜处理图像,怎么进行纹理造型,还有怎么使用不同的坐标系绘图,怎么使用噪声和网格噪声生成复杂纹理、以及今天学习的怎么使用后期处理通道增强图像。我把核心的内容总结成了一张脑图,你可以借助它,来复习巩固这一模块的内容,查缺补漏。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/48/0bcbeb9b391a1b7392b69f9ceaa66548.jpg" alt="">
|
||||
|
||||
## 小试牛刀
|
||||
|
||||
第一题,今天,我们实现的烟雾扩散效果还不够完善,也不够有趣,你能试着改进它,让它变得更有趣吗?你可以参考我给出的两个建议,也可以试试其他的效果。
|
||||
|
||||
1.给定一个向量,表示风向和风速,让烟雾随着这个风扩散,这个风可以随着时间慢慢变化(你可以使用前面学过的噪声来实现风的变化),看看能够做出什么效果。
|
||||
|
||||
2.尝试让烟雾跟随着鼠标移动轨迹扩散,达到类似下面的效果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/89/f180f7ca5c2yy5a9304b504893cf8289.gif" alt="">
|
||||
|
||||
如果你觉得难以实现,这里有[一篇文章](https://gamedevelopment.tutsplus.com/tutorials/how-to-write-a-smoke-shader--cms-25587)详细讲了烟雾生成的方法,你可以仔细研究一下。
|
||||
|
||||
第二题,实际上,后期处理通道可不止是实现Blur滤镜、辉光或者烟雾效果那么简单,它还可以实现很多不同的功能。比如,SpriteJS官网上那种类似于[探照灯]((https://spritejs.org/demo/#/shader_and_pass/pass))的效果,就是用后期处理通道实现的。你可以用gl-renderer来实现这类效果吗?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5d/14/5df356fdb628efe1173089e90238e814.gif" alt="">
|
||||
|
||||
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!
|
||||
|
||||
## 源码
|
||||
|
||||
完整的示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/pass)
|
||||
|
||||
## 推荐阅读
|
||||
|
||||
[1] [原始FBO对象的创建和使用方法](https://blog.csdn.net/xufeng0991/article/details/76736971)
|
||||
|
||||
[2] [How to Write a Smoke Shader](https://gamedevelopment.tutsplus.com/tutorials/how-to-write-a-smoke-shader--cms-25587)
|
||||
Reference in New Issue
Block a user