你好,我是月影。
通过前面的课程,我们初步了解了浏览器的图形系统,也学会了使用基本的数学和几何方法来生成和处理图像,还能用简单的图形组合来构成复杂的图案。从这一节课开始,我们进入一个新的模块,开始学习像素处理。
在可视化领域里,我们常常需要处理大规模的数据,比如,需要呈现数万甚至数十万条信息在空间中的分布情况。如果我们用几何绘制的方式将这些信息一一绘制出来,性能可能就会很差。
这时,我们就可以将这些数据简化为像素点进行处理。这种处理图像的新思路就叫做像素化。在可视化应用中,图片像素化处理是一个很重要手段,它能够在我们将原始数据信息转换成图形后,进一步处理图形的细节,突出我们想要表达的信息,还能让视觉呈现更有冲击力。
因为像素化的内容比较复杂,能做的事情也非常多,所以我们会用五节课的时间来讨论。今天是第一节课,我们先来看看图片像素化的基本思路和方法,体会如何用像素化来处理照片,从而达到“美颜”的效果。
首先,我们来理解两个基础的概念。第一个是像素化。所谓像素化,就是把一个图像看成是由一组像素点组合而成的。每个像素点负责描述图像上的一个点,并且带有这个点的基本绘图信息。那对于一张800像素宽、600像素高的图片来说,整张图一共就有48万个像素点。
这么多的像素点是怎么存储的呢?Canvas2D以4个通道来存放每个像素点的颜色信息,每个通道是8个比特位,也就是0~255的十进制数值,4个通道对应RGBA颜色的四个值。后面,我们会用RGBA通道来分别代表它们。
知道了什么是像素化,那像素处理又是怎么一回事呢?像素处理实际上就是我们为了达到特定的视觉效果,用程序来处理图像上每个像素点。像素处理的应用非常广泛,能实现的效果也非常多。下面,我会列举几个常用的效果,希望你通过它们能理解像素处理的一般方法和思路。
在可视化中,当我们要给用户强调某些信息的时候,一般会将我们不想强调的信息区域置成灰度状态。这样,用户就能够快速抓住我们想要表达的重点了。这个过程就是灰度化图片。简单来说就是将一张彩色图片变为灰白色图片。具体的实现思路是,我们先将该图片的每个像素点的R、G、B通道的值进行加权平均,然后将这个值作为每个像素点新的R、G、B通道值,具体公式如下:
其中R、G、B是原图片中的R、G、B通道的色值,V是加权平均色值,a、b、c是加权系数,满足 (a + b + c) = 1。
好了,灰度化的原理你已经知道了,下面我们通过一个具体的例子来演示一下实际操作的过程。首先,我们写一段简单的JavaScript代码,通过这段代码把一张图片给加载并绘制到Canvas上,代码如下:
<canvas id="paper" width="0" height="0"></canvas>
<script>
function loadImage(src) {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) => {
img.onload = () => {
resolve(img);
};
img.src = src;
});
}
const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
// 异步加载图片
const img = await loadImage('https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg');
const {width, height} = img;
// 将图片绘制到 canvas
canvas.width = width;
canvas.height = height;
context.drawImage(img, 0, 0);
}());
</script>
这段代码开始创建一个image元素,然后通过image元素的src属性异步加载图片,加载完成后,通过canvas的2d上下文对象的drawImage方法,将图片元素绘制到canvas上。这张图片如下图所示:
接下来,我们要获取每个像素点的R、G、B值。具体的操作就是通过canvas的2d上下文,获取图片剪裁区的数据imgData。那什么是imgData呢?imgData是我们在像素处理中经常用到的对象,它是一个ImageData对象,它有3个属性,分别是width、height和data。其中width表示剪裁区的宽度属性,height表示剪裁区的高度属性,data用来存储图片的全部像素信息。
宽、高属性我就不用多说了,我来重点说说data是怎么保存图片全部像素信息的。首先,图片的全部像素信息会以类型数组(Uint8ClampedArray)的形式保存在ImageData对象的data属性里,而类型数组的每4个元素组成一个像素的信息,这四个元素依次表示该像素的RGBA四通道的值,所以它的数据结构如下:
data[0] // 第1行第1列的红色通道值
data[1] // 第1行第1列的绿色通道值
data[2] // 第1行第1列的蓝色通道值
data[3] // 第1行第1列的Alpha通道值
data[4] // 第1行第2列的红色通道值
data[5] // 第1行第2列的绿色通道值
...
结合这个结构,我们可以得出data属性的类型数组的总长度:width * height * 4。这是因为图片一共是width * height个像素点,每个像素点有4个通道,所以总长度是像素点的4倍。
知道了数组长度以后,接着,我们就可以遍历data数组,读取每个像素的RGBA四通道的值,将原本的RGBA值都用一个加权平均值0.2126 r + 0.7152 g + 0.0722 * b替代了,代码如下:
for(let i = 0; i < width * height * 4; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2],
a = data[i + 3];
// 对RGB通道进行加权平均
const v = 0.2126 * r + 0.7152 * g + 0.0722 * b;
data[i] = v;
data[i + 1] = v;
data[i + 2] = v;
data[i + 3] = a;
}
这里你可能会觉得奇怪,我们为什么用0.2126、0.7152和0.0722这三个权重,而不是都用算术平均值1/3呢?这是因为,人的视觉对R、G、B三色通道的敏感度是不一样的,对绿色敏感度高,所以加权值高,对蓝色敏感度低,所以加权值低。
最后,我们将处理好的数据写回到Canvas中去。这样,我们就得到了一张经过灰度化后的图片。
灰度化图片的过程非常简单,我们一起来总结一下。首先,我们加载一张图片将它绘制到canvas,接着我们通过getImageData获取imageData信息,再通过imageData.data遍历图像上的所有像素点,对每个像素点的RGBA值进行加权平均处理,然后将处理好的信息回写到canvas中去。
我把灰度化图片的过程总结了一张流程图,你也可以参考它来理解。
实际上,灰度化只是像素颜色处理中一个最简单的应用,除此以外,我们还可以对像素颜色做其他变换,比如增强或减弱某个通道的色值,改变颜色的亮度、对比度、饱和度、色相等等。
那为了方便讲解,也为了更好地复用代码实现其他的功能,我先重构一下上面的代码,将我们最关注的循环处理每个像素的颜色信息这一步单独剥离出来,再把其他步骤都分解并抽象成通用的模块以便于实现其他效果的时候引入。
重构代码的实现思路是,先创建一个lib/utils.js文件,然后把加载图片的函数loadImage,获取imageData对象的函数getImageData,以及遍历imageData中的类型数组的函数traverse,都添加到lib/utils.js文件中。代码如下:
// lib/utls.js
// 异步加载图片
export function loadImage(src) {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) => {
img.onload = () => {
resolve(img);
};
img.src = src;
});
}
const imageDataContext = new WeakMap();
// 获得图片的 imageData 数据
export function getImageData(img, rect = [0, 0, img.width, img.height]) {
let context;
if(imageDataContext.has(img)) context = imageDataContext.get(img);
else {
const canvas = new OffscreenCanvas(img.width, img.height);
context = canvas.getContext('2d');
context.drawImage(img, 0, 0);
imageDataContext.set(img, context);
}
return context.getImageData(...rect);
}
// 循环遍历 imageData 数据
export function traverse(imageData, pass) {
const {width, height, data} = imageData;
for(let i = 0; i < width * height * 4; i += 4) {
const [r, g, b, a] = pass({
r: data[i] / 255,
g: data[i + 1] / 255,
b: data[i + 2] / 255,
a: data[i + 3] / 255,
index: i,
width,
height,
x: ((i / 4) % width) / width,
y: Math.floor(i / 4 / width) / height});
data.set([r, g, b, a].map(v => Math.round(v * 255)), i);
}
return imageData;
}
我们这样做了之后,像素处理的应用代码就可以得到简化。简化后的代码如下:
import {loadImage, getImageData, traverse} from './lib/util.js';
const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
// 异步加载图片
const img = await loadImage('assets/girl1.jpg');
// 获取图片的 imageData 数据对象
const imageData = getImageData(img);
// 遍历 imageData 数据对象
traverse(imageData, ({r, g, b, a}) => { // 对每个像素进行灰度化处理
const v = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return [v, v, v, a];
});
// 更新canvas内容
canvas.width = imageData.width;
canvas.height = imageData.height;
context.putImageData(imageData, 0, 0);
}());
这样做的好处是,traverse函数会自动遍历图片的每个像素点,把获得的像素信息传给参数中的回调函数处理。这样,我们就只关注 traverse 函数里面的处理过程就可以了。
在灰度化图片的例子中,我们用加权平均的计算公式来替换图片的RGBA的值。这本质上其实是利用线性方程组改变了图片中每一个像素的RGB通道的原色值,将每个通道的色值映射为一个新色值。
除了加权平均的计算公式以外,我们还可以用其他的线性方程组来实现各种不同的像素变换效果。比如说,要想实现改变图片的亮度,我们可以将R、G、B通道的值都乘以一个常量p,公式如下:
这里的p是一个常量,如果它小于1,那么R、G、B值就会变小,图片就会变暗,也就更接近于黑色了。相反,如果p大于1,图片就会变亮,更接近白色。这样一来,我们用不同的公式就可以将像素的颜色处理成我们所期望的结果了。
但如果你想要实现不同的颜色变换,就必须要使用不同的方程组,这会让我们使用起来非常麻烦。那你肯定想问了,有没有一种方式,可以更通用地实现更多的颜色变换效果呢?当然是有的,我们可以引入一个颜色矩阵,它能够处理几乎所有的颜色变换类滤镜。
我们创建一个4*5颜色矩阵,让它的第一行决定红色通道,第二行决定绿色通道,第三行决定蓝色通道,第四行决定Alpha通道。
那如果要改变一个像素的颜色效果,我们只需要将该矩阵与像素的颜色向量相乘就可以了。
这样一来,灰度化图片的处理过程,就可以描述成如下的颜色矩阵:
function grayscale(p = 1) {
const r = 0.2126 * p;
const g = 0.7152 * p;
const b = 0.0722 * p;
return [
r + 1 - p, g, b, 0, 0,
r, g + 1 - p, b, 0, 0,
r, g, b + 1 - p, 0, 0,
0, 0, 0, 1, 0,
];
}
注意,这里我们引入了一个参数p,它是一个0~1的值,表示灰度化的程度,1是完全灰度化,0是完全不灰度,也就是保持原始色彩。这样一来,我们通过调节p的值就可以改变图片灰度化的程度。因此这个灰度化矩阵,比前面直接用灰度化公式更加通用。
因为p的取值范围是0~1,所以p的取值可以分成三种情况。下面,我们一起来分析一下。
第一种,p等于0,这个时候,r、g、b的值也都是0,所以返回的矩阵就退化成单位矩阵,代码如下。这样一来,新色值和原色值就完全相同了。
[
1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 1, 0
]
第二种情况当p等于1的时候,这个矩阵就正好对应我们前面的灰度化公式。
[
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
]
第三种取值情况,当p处于0~1之间的时候,颜色矩阵的值就在完全灰度的矩阵和单位矩阵之间线性变化。这样我们就实现了可调节的灰度化颜色矩阵。
但是,光有颜色矩阵还不行,要想实现不同的颜色变化,根据前面的公式,我们还得让旧的色值与颜色矩阵相乘,把新的色值计算出来。
为了方便处理,我们可以增加处理颜色矩阵的模块。让它包含两个函数,一个是处理颜色矩阵的矩阵乘法运算multiply函数,另一个是将RGBA颜色通道组成的向量与颜色矩阵相乘,得到新色值的transformColor函数。
// lib/color-matrix.js
// 将 color 通过颜色矩阵映射成新的色值返回
export function transformColor(color, ...matrix) {
// 颜色向量与矩阵相乘
... 省略的代码
}
// 将颜色矩阵相乘
export function multiply(a, b) {
// 颜色矩阵相乘
...省略的代码
}
那你可能想问,为什么我们这里不仅提供了处理色值映射的transformColor,还提供了一个矩阵乘法的multiply方法呢?
这是因为根据矩阵运算的性质,我们可以将多次颜色变换的过程,简化为将相应的颜色矩阵相乘,然后用最终的那个矩阵对颜色值进行映射。具体的过程你可以看看我给出的流程图。
这样,灰度化图片的实现部分就可以写成如下代码:
...省略代码...
traverse(imageData, ({r, g, b, a}) => {
return transformColor([r, g, b, a], grayscale(1));
});
...省略代码...
这里的grayscale函数返回了实现灰度化的颜色矩阵,而要实现其他颜色变换效果,我们可以定义其他函数返回其他的颜色矩阵。这种返回颜色矩阵的函数,我们一般称为颜色滤镜函数。
抽象出了颜色滤镜函数之后,我们处理颜色代码的过程可以规范成如下图所示的过程:
我们还可以增加其他的颜色滤镜函数,比如:
function channel({r = 1, g = 1, b = 1}) {
return [
r, 0, 0, 0, 0,
0, g, 0, 0, 0,
0, 0, b, 0, 0,
0, 0, 0, 1, 0,
];
}
这个channel滤镜函数可以过滤或增强某个颜色通道。
// 增强红色通道,减弱绿色通道
traverse(imageData, ({r, g, b, a}) => {
return transformColor([r, g, b, a], channel({r: 1.5, g: 0.75}));
});
举个例子,当我们调用channel({r: 1.5, g: 0.75})的时候,红色通道的值被映射为原来的1.5倍,绿色通道的值则被映射为0.75倍。这样得到的图片就显得比原来要红。
在处理图片时,我们还会用到有一些常用的颜色滤镜,比如,可以修改图片的亮度(Brightness)、饱和度(Saturate)、对比度(Constrast)、透明度(Opacity),还有对图片反色(Invert)和旋转色相(HueRotate)。这些滤镜函数的结构都是一样的,只是返回的矩阵不同而已。
它们的滤镜函数如下,你结合上节课我们学过的颜色理论很容易就可以理解了,我就不详细讲了。
// 改变亮度,p = 0 全暗,p > 0 且 p < 1 调暗,p = 1 原色, p > 1 调亮
function brightness(p) {
return [
p, 0, 0, 0, 0,
0, p, 0, 0, 0,
0, 0, p, 0, 0,
0, 0, 0, 1, 0,
];
}
// 饱和度,与grayscale正好相反
// p = 0 完全灰度化,p = 1 原色,p > 1 增强饱和度
function saturate(p) {
const r = 0.2126 * (1 - p);
const g = 0.7152 * (1 - p);
const b = 0.0722 * (1 - p);
return [
r + p, g, b, 0, 0,
r, g + p, b, 0, 0,
r, g, b + p, 0, 0,
0, 0, 0, 1, 0,
];
}
// 对比度, p = 1 原色, p < 1 减弱对比度,p > 1 增强对比度
function contrast(p) {
const d = 0.5 * (1 - p);
return [
p, 0, 0, 0, d,
0, p, 0, 0, d,
0, 0, p, 0, d,
0, 0, 0, 1, 0,
];
}
// 透明度,p = 0 全透明,p = 1 原色
function opacity(p) {
return [
1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, p, 0,
];
}
// 反色, p = 0 原色, p = 1 完全反色
function invert(p) {
const d = 1 - 2 * p;
return [
d, 0, 0, 0, p,
0, d, 0, 0, p,
0, 0, d, 0, p,
0, 0, 0, 1, 0,
]
}
// 色相旋转,将色调沿极坐标转过deg角度
function hueRotate(deg) {
const rotation = deg / 180 * Math.PI;
const cos = Math.cos(rotation),
sin = Math.sin(rotation),
lumR = 0.2126,
lumG = 0.7152,
lumB = 0.0722;
return [
lumR + cos * (1 - lumR) + sin * (-lumR), lumG + cos * (-lumG) + sin * (-lumG), lumB + cos * (-lumB) + sin * (1 - lumB), 0, 0,
lumR + cos * (-lumR) + sin * (0.143), lumG + cos * (1 - lumG) + sin * (0.140), lumB + cos * (-lumB) + sin * (-0.283), 0, 0,
lumR + cos * (-lumR) + sin * (-(1 - lumR)), lumG + cos * (-lumG) + sin * (lumG), lumB + cos * (1 - lumB) + sin * (lumB), 0, 0,
0, 0, 0, 1, 0,
];
}
当然了,在实际工作中为了实现更多样的效果,我们经常需要叠加使用多种滤镜函数。那这些滤镜函数该如何叠加呢?根据我们前面说过的矩阵乘法的特性,其实只要将这些滤镜函数返回的滤镜矩阵先相乘,然后把得到的矩阵再与输入的RGBA颜色向量相乘就可以了。
我们之前封装的transformColor函数,就完全考虑到了应用多个滤镜函数的需求,它的参数可以接受多个矩阵,如果传给它多个矩阵,它会将每个矩阵一一进行乘法运算。
// 将 color 通过颜色矩阵映射成新的色值返回
export function transformColor(color, ...matrix) {
...
matrix = matrix.reduce((m1, m2) => multiply(m1, m2));
...
}
所以,如果在一些需要暖色调的场景中,我们想让一张图片变得有“阳光感”,那我们可以使用叠加channel函数中的红色通道、brightness函数和 saturate函数来实现这一效果。
具体的实现代码和效果如下所示。
traverse(imageData, ({r, g, b, a}) => {
return transformColor(
[r, g, b, a],
channel({r: 1.2}), // 增强红色通道
brightness(1.2), // 增强亮度
saturate(1.2), // 增强饱和度
);
});
你肯定发现了,刚才我们讲的颜色滤镜都比较简单。没错,其实它们都是一些简单滤镜。那在实际的可视化项目中,我们通常会使用颜色滤镜来增强视觉呈现的细节,而用一种相对复杂的滤镜来模糊背景,从而突出我们要呈现给用户的内容。这个复杂滤镜就叫做高斯模糊(Gaussian Blur)。
高斯模糊的原理与颜色滤镜不同,高斯模糊不是单纯根据颜色矩阵计算当前像素点的颜色值,而是会按照高斯分布的权重,对当前像素点及其周围像素点的颜色按照高斯分布的权重加权平均。这样做,我们就能让图片各像素色值与周围色值的差异减小,从而达到平滑,或者说是模糊的效果。所以,高斯模糊是一个非常重要的平滑效果滤镜(Blur Filters)。
高斯模糊的算法分两步,第一步是生成高斯分布矩阵,这个矩阵的作用是按照高斯函数提供平滑过程中参与计算的像素点的加权平均权重。代码如下:
function gaussianMatrix(radius, sigma = radius / 3) {
const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
const b = -1 / (2 * sigma ** 2);
let sum = 0;
const matrix = [];
for(let x = -radius; x <= radius; x++) {
const g = a * Math.exp(b * x ** 2);
matrix.push(g);
sum += g;
}
for(let i = 0, len = matrix.length; i < len; i++) {
matrix[i] /= sum;
}
return {matrix, sum};
}
那高斯分布的原理是什么呢?它其实就是正态分布,简单来说就是将当前像素点的颜色值设置为附近像素点颜色值的加权平均,而距离当前像素越近的点的权重越高,权重分布满足正态分布。
因为高斯分布涉及比较专业的数学知识,所以要展开细讲会非常复杂,而且我们在实际工作中只要理解原理并且会使用相关公式就够了。因此,你要记住我这里给出的二维高斯函数公式。
这个公式其实就是上面代码中的计算式:
const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
const b = -1 / (2 * sigma ** 2);
const g = a * Math.exp(b * x ** 2);
第二步,对图片在x轴、y轴两个方向上分别进行高斯运算。也就是沿着图片的宽、高方向对当前像素和它附近的像素,应用上面得出的权重矩阵中的值进行加权平均。
实现代码如下:
export function gaussianBlur(pixels, width, height, radius = 3, sigma = radius / 3) {
const {matrix, sum} = gaussianMatrix(radius, sigma);
// x 方向一维高斯运算
for(let y = 0; y < height; y++) {
for(let x = 0; x < width; x++) {
let r = 0,
g = 0,
b = 0;
for(let j = -radius; j <= radius; j++) {
const k = x + j;
if(k >= 0 && k < width) {
const i = (y * width + k) * 4;
r += pixels[i] * matrix[j + radius];
g += pixels[i + 1] * matrix[j + radius];
b += pixels[i + 2] * matrix[j + radius];
}
}
const i = (y * width + x) * 4;
// 除以 sum 是为了消除处于边缘的像素, 高斯运算不足的问题
pixels[i] = r / sum;
pixels[i + 1] = g / sum;
pixels[i + 2] = b / sum;
}
}
// y 方向一维高斯运算
...省略代码
return pixels;
}
在实现了高斯模糊函数之后,我们就可以对前面例子中的图片进行高斯模糊处理了,代码如下:
...省略代码...
// 获取图片的 imageData 数据对象
const imageData = getImageData(img);
// 对imageData应用高斯模糊
gaussianBlur(data, width, height);
...省略代码...
由于高斯模糊不是处理单一像素,而是处理一个范围内的所有像素,因此我们不能采用前面的 traverse 遍历函数的方法,而是整体对图片所有像素应用高斯模糊函数。我们最终得到的效果如下图:
你会发现,图片整体都变得“模糊”了。那这种模糊效果就完全可以用来给图片“磨皮”。处理后的图片对比效果如下:
在这里,我们不仅给图片加了高斯模糊,还用灰度化、增强了饱和度、增强了对比度和亮度。这样图片上的人的皮肤就会显得更白皙。我建议你,可以试着自己动手来实现看看。
那在上面这几个例子中,我们都是自己通过Canvas的getImageData方法拿到像素数据,然后遍历读取或修改像素信息。实际上,如果只是按照某些特定规则改变一个图像上的所有像素,浏览器提供了更简便的方法:CSS滤镜。
前面讲过的几种图片效果,我们都可以通过CSS滤镜实现。比如灰度化图片可以直接使用img元素来,代码如下:
<img src="https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg" style="filter:grayscale(100%)">
同样美颜效果我们也可以用CSS滤镜来实现:
<img src="https://p0.ssl.qhimg.com/t01161037b5fe87f236.jpg"
style="filter:blur(1.5px) grayscale(0.5) saturate(1.2) contrast(1.1) brightness(1.2)">
除此以外,比较新的浏览器上还实现了原生的Canvas滤镜,与CSS滤镜相对应。CSS滤镜和Canvas滤镜都能实现非常丰富的滤镜效果,在处理视觉呈现上很有用。尽管CSS滤镜和Canvas滤镜都很好用,但是在实现效果上都有局限性,它们一般只能实现比较固定的视觉效果。这对于可视化来说,这并不够用。
这个时候像素处理的优势就体现出来了,用像素处理图片更灵活,因为它可以实现滤镜功能,还可以实现更加丰富的效果,包括一些非常炫酷的视觉效果,这正是可视化领域所需要的。
这一节课我们学习了图片像素处理的基本原理。你要掌握的核心概念有滤镜函数、高斯模糊滤镜以及内置滤镜。
在像素处理的过程中,我们可以利用Canvas的getImageData API来获取图片的像素数据,然后遍历图片的每个像素点,最后用线性方程或者矩阵变换来改变图片的像素颜色。我们可以定义函数来生成矩阵变换,这些生成矩阵变换的函数就是滤镜函数。
像grayscale一类的函数,就是比较简单的滤镜函数了。除此以外,我们还学习了一类复杂滤镜,其中最基础的一种是高斯模糊滤镜,我们可以用它的平滑效果来给照片“美颜”。实际上,高斯模糊滤镜经常会用来做背景模糊,以突出主题内容。
而且,我们还探讨了像素化的优势,虽然浏览器提供了内置的Canvas滤镜和CSS滤镜可以实现大部分滤镜函数的功能,我们可以直接使用它们。但是,内置滤镜实现不了一些更加复杂的视觉效果,而像素化可以。不过,我也会在后面的课程中,继续和你深入讨论一些复杂的滤镜,用它们来生成复杂效果。
欢迎留言和我分享你的练习过程,如果有收获,欢迎你把这节课分享给你的朋友。
评论