电脑或者手机上做图像处理有很多方式,但是目前为止最高效的方法是有效地使用图形处理单元,或者叫 GPU。你的手机包含两个不同的处理单元,CPU 和 GPU。CPU 是个多面手,并且不得不处理所有的事情,而 GPU 则可以集中来处理好一件事情,就是并行地做浮点运算。事实上,图像处理和渲染就是在将要渲染到窗口上的像素上做许许多多的浮点运算。
通过有效的利用 GPU,可以成百倍甚至上千倍地提高手机上的图像渲染能力。如果不是基于 GPU 的处理,手机上实时高清视频滤镜是不现实,甚至不可能的。
着色器 (shader) 是我们利用这种能力的工具。着色器是用着色语言写的小的,基于 C 语言的程序。现在有很许多种着色语言,但你如果做 OS X 或者 iOS 开发的话,你应该专注于 OpenGL 着色语言,或者叫 GLSL。你可以将 GLSL 的理念应用到其他的更专用的语言 (比如 Metal) 上去。这里我们即将介绍的概念与和 Core Image 中的自定义核矩阵有着很好的对应,尽管它们在语法上有一些不同。
这个过程可能会很让人恐惧,尤其是对新手。这篇文章的目的是让你接触一些写图像处理着色器的必要的基础信息,并将你带上书写你自己的图像处理着色器的道路。
什么是着色器
在 OpenGL ES 中你必须创建两种着色器:顶点着色器 (vertex shaders) 和片段着色器 (fragment shaders)。这两种着色器是一个完整程序的两半,你不能仅仅创建其中任何一个;想创建一个完整的着色程序,两个都是必须存在。
顶点着色器定义了在 2D 或者 3D 场景中几何图形是如何处理的。一个顶点指的是 2D 或者 3D 空间中的一个点。在图像处理中,有 4 个顶点:每一个顶点代表图像的一个角。顶点着色器设置顶点的位置,并且把位置和纹理坐标这样的参数发送到片段着色器。
然后 GPU 使用片段着色器在对象或者图片的每一个像素上进行计算,最终计算出每个像素的最终颜色。图片,归根结底,实际上仅仅是数据的集合。图片的文档包含每一个像素的各个颜色分量和像素透明度的值。因为对每一个像素,算式是相同的,GPU 可以流水线作业这个过程,从而更加有效的进行处理。使用正确优化过的着色器,在 GPU 上进行处理,将使你获得百倍于在 CPU 上用同样的过程进行图像处理的效率。
我们的第一个着色器的例子
顶点着色器
好吧,关于着色器我们说的足够多了。我们来看一个实践中真实的着色器程序。这里是一个 GPUImage 中一个基础的顶点着色器:
NSString *const kGPUImageVertexShaderString = SHADER_STRING ( attribute vec4 position; attribute vec4 inputTextureCoordinate; varying vec2 textureCoordinate; void main() { gl_Position = position; textureCoordinate = inputTextureCoordinate.xy; } );
我们一句一句的来看:
attribute vec4 position;
像所有的语言一样,着色器语言的设计者也为常用的类型创造了特殊的数据类型,例如 2D 和 3D 坐标。这些类型是向量,稍后我们会深入更多。回到我们的应用程序的代码,我们创建了一系列顶点,我们为每个顶点提供的参数里的其中一个是顶点在画布中的位置。然后我们必须告诉我们的顶点着色器它需要接收这个参数,我们稍后会将它用在某些事情上。
attribute vec4 inputTextureCoordinate;
现在你或许很奇怪,为什么我们需要一个纹理坐标。我们不是刚刚得到了我们的顶点位置了吗?难道它们不是同样的东西吗?
其实它们并非一定是同样的东西。纹理坐标是纹理映射的一部分。这意味着你想要对你的纹理进行某种滤镜操作的时候会用到它。左上角坐标是 (0,0)。右上角的坐标是 (1,0)。如果我们需要在图片内部而不是边缘选择一个纹理坐标,我们需要在我们的应用中设定的纹理坐标就会与此不同,像是 (.25, .25) 是在图片左上角向右向下各图片高宽 1/4 的位置。在我们当前的图像处理应用里,我们希望纹理坐标和顶点位置一致,因为我们想覆盖到图片的整个长度和宽度。有时候你或许会希望这些坐标是不同的,所以需要记住它们未必是相同的坐标。在这个例子中,顶点坐标空间从 -1.0 延展到 1.0,而纹理坐标是从 0.0 到 1.0。
varying vec2 textureCoordinate;
因为顶点着色器负责和片段着色器交流,所以我们需要创建一个变量和它共享相关的信息。在图像处理中,片段着色器需要的唯一相关信息就是顶点着色器现在正在处理哪个像素。
gl_Position = position;
gl_Position 是一个内建的变量。GLSL 有一些内建的变量,在片段着色器的例子中我们将看到其中的一个。这些特殊的变量是可编程管道的一部分,API 会去寻找它们,并且知道如何和它们关联上。在这个例子中,我们指定了顶点的位置,并且把它从我们的程序中反馈给渲染管线。
textureCoordinate = inputTextureCoordinate.xy;
最后,我们取出这个顶点中纹理坐标的 X 和 Y 的位置。我们只关心 inputTextureCoordinate 中的前两个参数,X 和 Y。这个坐标最开始是通过 4 个属性存在顶点着色器里的,但我们只需要其中的两个。我们拿出需要的属性,然后赋值给一个将要和片段着色器通信的变量,而不是把更多的属性反馈给片段着色器。
在大多数图像处理程序中,顶点着色器都差不多,所以,这篇文章接下来的部分,我们将集中讨论片段着色器。
片段着色器
看过了我们简单的顶点着色器后,我们再来看一个可以实现的最简单的片段着色器:一个直通滤镜:
varying highp vec2 textureCoordinate; uniform sampler2D inputImageTexture; void main() { gl_FragColor = texture2D(inputImageTexture, textureCoordinate); }
这个着色器实际上不会改变图像中的任何东西。它是一个直通着色器,意味着我们输入每一个像素,然后输出完全相同的像素。我们来一句句的看:
varying highp vec2 textureCoordinate;
因为片段着色器作用在每一个像素上,我们需要一个方法来确定我们当前在分析哪一个像素/片段。它需要存储像素的 X 和 Y 坐标。我们接收到的是当前在顶点着色器被设置好的纹理坐标。
uniform sampler2D inputImageTexture;
为了处理图像,我们从应用中接收一个图片的引用,我们把它当做一个 2D 的纹理。这个数据类型被叫做 sampler2D ,这是因为我们要从这个 2D 纹理中采样出一个点来进行处理。
gl_FragColor = texture2D(inputImageTexture, textureCoordinate);
这是我们碰到的第一个 GLSL 特有的方法:texture2D,顾名思义,创建一个 2D 的纹理。它采用我们之前声明过的属性作为参数来决定被处理的像素的颜色。这个颜色然后被设置给另外一个内建变量,gl_FragColor。因为片段着色器的唯一目的就是确定一个像素的颜色,gl_FragColor 本质上就是我们片段着色器的返回语句。一旦这个片段的颜色被设置,接下来片段着色器就不需要再做其他任何事情了,所以你在这之后写任何的语句,都不会被执行。
就像你看到的那样,写着色器很大一部分就是了解着色语言。即使着色语言是基于 C 语言的,依然有很多怪异和细微的差别让它和普通的 C 语言有不同。
输入,输出,以及精度修饰 (Precision Qualifiers)
看一看我们的直通着色器,你会注意到有一个属性被标记为 “varying”,另一个属性被标记为 “uniform”。
这些变量是 GLSL 中的输入和输出。它允许从我们应用的输入,以及在顶点着色器和片段着色器之间进行交流。
在 GLSL 中,实际有三种标签可以赋值给我们的变量:
- Uniforms
- Attributes
- Varyings
Uniforms 是一种外界和你的着色器交流的方式。Uniforms 是为在一个渲染循环里不变的输入值设计的。如果你正在应用茶色滤镜,并且你已经指定了滤镜的强度,那么这些就是在渲染过程中不需要改变的事情,你可以把它作为 Uniform 输入。 Uniform 在顶点着色器和片段着色器里都可以被访问到。
Attributes 仅仅可以在顶点着色器中被访问。Attribute 是在随着每一个顶点不同而会发生变动的输入值,例如顶点的位置和纹理坐标等。顶点着色器利用这些变量来计算位置,以它们为基础计算一些值,然后把这些值以 varyings 的方式传到片段着色器。
最后,但同样重要的,是 varyings 标签。Varying 在顶点着色器和片段着色器都会出现。Varying 是用来在顶点着色器和片段着色器传递信息的,并且在顶点着色器和片段着色器中必须有匹配的名字。数值在顶点着色器被写入到 varying ,然后在片段着色器被读出。被写入 varying 中的值,在片段着色器中会被以插值的形式插入到两个顶点直接的各个像素中去。
回看我们之前写的简单的着色器的例子,在顶点着色器和片段着色器中都用 varying 声明了 textureCoordinate。我们在顶点着色器中写入 varying 的值。然后我们把它传入片段着色器,并在片段着色器中读取和处理。
在我们继续之前,最后一件要注意的事。看看创建的这些变量。你会注意到纹理坐标有一个叫做 highp 的属性。这个属性负责设置你需要的变量精度。因为 OpenGL ES 被设计为在处理能力有限的系统中使用,精度限制被加入进来可以提高效率。
如果不需要非常高的精度,你可以进行设定,这或许会允许在一个时钟循环内处理更多的值。相反的,在纹理坐标中,我们需要尽可能的确保精确,所以我们具体说明确实需要额外的精度。
精度修饰存在于 OpenGL ES 中,因为它是被设计用在移动设备中的。但是,在老版本的桌面版的 OpenGL 中则没有。因为 OpenGL ES 实际上是 OpenGL 的子集,你几乎总是可以直接把 OpenGL ES 的项目移植到 OpenGL。如果你这样做,记住一定要在你的桌面版着色器中去掉精度修饰。这是很重要的一件事,尤其是当你计划在 iOS 和 OS X 之间移植项目时。
向量
在 GLSL 中,你会用到很多向量和向量类型。向量是一个很棘手的话题,它们表面上看起来很直观,但是因为它们有很多用途,这使我们在使用它们时常常会感到迷惑。
在 GLSL 环境中,向量是一个类似数组的特殊的数据类型。每一种类型都有固定的可以保存的元素。深入研究一下,你甚至可以获得数组可以存储的数值的精确的类型。但是在大多数情况下,只要使用通用的向量类型就足够了。
有三种向量类型你会经常看到:
- vec2
- vec3
- vec4
这些向量类型包含特定数量的浮点数:vec2 包含两个浮点数,vec3 包含三个浮点数,vec4 包含四个浮点数。
这些类型可以被用在着色器中可能被改变或者持有的多种数据类型中。在片段着色器中,很明显 X 和 Y 坐标是的你想保存的信息。 (X,Y) 存储在 vec2 中就很合适。
在图像处理过程中,另一个你可能想持续追踪的事情就是每个像素的 R,G,B,A 值。这些可以被存储在 vec4 中。
矩阵
现在我们已经了解了向量,接下来继续了解矩阵。矩阵和向量很相似,但是它们添加了额外一层的复杂度。矩阵是一个浮点数数组的数组,而不是单个的简单浮点数数组。
类似于向量,你将会经常处理的矩阵对象是:
- mat2
- mat3
- mat4
vec2 保存两个浮点数,mat 保存相当于两个 vec2 对象的值。将向量对象传递到矩阵对象并不是必须的,只需要有足够填充矩阵的浮点数即可。在 mat2 中,你需要传入两个 vec2 或者四个浮点数。因为你可以给向量命名,而且相比于直接传浮点数,你只需要负责两个对象,而不是四个,所以非常推荐使用封装好的值来存储你的数字,这样更利于追踪。对于 mat4 会更复杂一些,因为你要负责 16 个数字,而不是 4 个。
在我们 mat2 的例子中,我们有两个 vec2 对象。每个 vec2 对象代表一行。每个 vec2 对象的第一个元素代表一列。构建你的矩阵对象的时候,确保每个值都放在了正确的行和列上是很重要的,否则使用它们进行运算肯定得不到正确的结果。
既然我们有了矩阵也有了填充矩阵的向量,问题来了:“我们要用它们做什么呢?“ 我们可以存储点和颜色或者其他的一些的信息,但是要如果通过修改它们来做一些很酷的事情呢?
向量和矩阵运算,也就是初等线性代数
我找到的最好的关于线性代数和矩阵是如何工作的资源是这个网站的更好的解释。我从这个网站偷来借鉴的一句引述就是:
线性代数课程的幸存者都成为了物理学家,图形程序员或者其他的受虐狂。
矩阵操作总体来说并不“难”;只不过它们没有被任何上下文解释,所以很难概念化地理解究竟为什么会有人想要和它们打交道。我希望能在给出一些它们在图形编程中的应用背景后,我们可以了解它们怎样帮助我们实现不可思议的东西。
线性代数允许你一次在很多值上进行操作。假想你有一组数,你想要每一个数乘以 2。你一般会一个个地顺次计算数值。但是因为对每一个数都进行的是同样的操作,所以你完全可以并行地实现这个操作。
我们举一个看起来可怕的例子,CGAffineTransforms。仿射转化是很简单的操作,它可以改变具有平行边的形状 (比如正方形或者矩形) 的大小,位置,或者旋转角度。
在这种时候你当然可以坐下来拿出笔和纸,自己去计算这些转化,但这么做其实没什么意义。GLSL 有很多内建的函数来进行这些庞杂的用来计算转换的函数。了解这些函数背后的思想才是最重要的。
GLSL 特有函数
这篇文章中,我们不会把所有的 GLSL 内建的函数都过一遍,不过你可以在 Shaderific 上找到很好的相关资源。很多 GLSL 函数都是从 C 语言数学库中的基本的数学运算导出的,所以解释 sin 函数是做什么的真的是浪费时间。我们将集中阐释一些更深奥的函数,从而达到这篇文章的目的,解释怎样才能充分利用 GPU 的性能的一些细节。
step(): GPU 有一个局限性,它并不能很好的处理条件逻辑。GPU 喜欢做的事情是接受一系列的操作,并将它们作用在所有的东西上。分支会在片段着色器上导致明显的性能下降,在移动设备上尤其明显。step() 通过允许在不产生分支的前提下实现条件逻辑,从而在某种程度上可以缓解这种局限性。如果传进 step() 函数的值小于阈值,step() 会返回 0.0。如果大于或等于阈值,则会返回 1.0。通过把这个结果和你的着色器的值相乘,着色器的值就可以被使用或者忽略,而不用使用 if() 语句。
mix(): mix 函数将两个值 (例如颜色值) 混合为一个变量。如果我们有红和绿两个颜色,我们可以用 mix() 函数线性插值。这在图像处理中很常用,比如在应用程序中通过一组独特的设定来控制效果的强度等。
*clamp(): GLSL 中一个比较一致的方面就是它喜欢使用归一化的坐标。它希望收到的颜色分量或者纹理坐标的值在 0.0 和 1.0 之间。为了保证我们的值不会超出这个非常窄的区域,我们可以使用 clamp() 函数。 clamp() 会检查并确保你的值在 0.0 和 1.0 之间。如果你的值小于 0.0,它会把值设为 0.0。这样做是为了防止一些常见的错误,例如当你进行计算时意外的传入了一个负数,或者其他的完全超出了算式范围的值。
更复杂的着色器的例子
我知道数学的洪水一定让你快被淹没了。如果你还能跟上我,我想举几个优美的着色器的例子,这会更有意义,这样你又有机会淹没在 GLSL 的潮水中。
饱和度调整
这是一个做饱和度调节的片段着色器。这个着色器出自 《图形着色器:理论和实践》一书,我强烈推荐整本书给所有对着色器感兴趣的人。
饱和度是用来表示颜色的亮度和强度的术语。一件亮红色的毛衣的饱和度要远比北京雾霾时灰色的天空的饱和度高得多。
在这个着色器上,参照人类对颜色和亮度的感知过程,我们有一些优化可以使用。一般而言,人类对亮度要比对颜色敏感的多。这么多年来,压缩软件体积的一个优化方式就是减少存储颜色所用的内存。
人类不仅对亮度比颜色要敏感,同样亮度下,我们对某些特定的颜色反应也更加灵敏,尤其是绿色。这意味着,当你寻找压缩图片的方式,或者以某种方式改变它们的亮度和颜色的时候,多放一些注意力在绿色光谱上是很重要的,因为我们对它最为敏感。
varying highp vec2 textureCoordinate; uniform sampler2D inputImageTexture; uniform lowp float saturation; // Values from "Graphics Shaders: Theory and Practice" by Bailey and Cunningham const mediump vec3 luminanceWeighting = vec3(0.2125, 0.7154, 0.0721); void main() { lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate); lowp float luminance = dot(textureColor.rgb, luminanceWeighting); lowp vec3 greyScaleColor = vec3(luminance); gl_FragColor = vec4(mix(greyScaleColor, textureColor.rgb, saturation), textureColor.w); }
我们一行行的看这个片段着色器的代码:
varying highp vec2 textureCoordinate; uniform sampler2D inputImageTexture; uniform lowp float saturation;
再一次,因为这是一个要和基础的顶点着色器通信的片段着色器,我们需要为输入纹理坐标和输入图片纹理声明一个 varyings 变量,这样才能接收到我们需要的信息,并进行过滤处理。这个例子中我们有一个新的 uniform 的变量需要处理,那就是饱和度。饱和度的数值是一个我们从用户界面设置的参数。我们需要知道用户需要多少饱和度,从而展示正确的颜色数量。
const mediump vec3 luminanceWeighting = vec3(0.2125, 0.7154, 0.0721);
这就是我们设置三个元素的向量,为我们的亮度来保存颜色比重的地方。这三个值加起来要为 1,这样我们才能把亮度计算为 0.0 – 1.0 之间的值。注意中间的值,就是表示绿色的值,用了 70% 的颜色比重,而蓝色只用了它的 10%。蓝色对我们的展示不是很好,把更多权重放在绿色上是很有意义的。
lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);
我们需要取样特定像素在我们图片/纹理中的具体坐标来获取颜色信息。我们将会改变它一点点,而不是想直通滤镜那样直接返回。
lowp float luminance = dot(textureColor.rgb, luminanceWeighting);
这行代码会让那些没有学过线性代数或者很早以前在学校学过但是很少用过的人看起来不那么熟悉。我们是在使用 GLSL 中的点乘运算。如果你记得在学校里曾用过点运算符来相乘两个数字的话,那么你就能明白是什么回事儿了。点乘计算以包含纹理颜色信息的vec4 为参数,舍弃 vec4 的最后一个不需要的元素,将它和相对应的亮度权重相乘。然后取出所有的三个值把它们加在一起,计算出这个像素综合的亮度值。
lowp vec3 greyScaleColor = vec3(luminance);
最后,我们把所有的片段组合起来。为了确定每个新的颜色是什么,我们使用刚刚学过的很好用的 mix 函数。mix 函数会把我们刚刚计算的灰度值和初始的纹理颜色以及我们得到的饱和度的信息相结合。
这就是一个很棒的,好用的着色器,它让你用主函数里的四行代码就可以把图片从彩色变到灰色,或者从灰色变到彩色。还不错,不是吗?