OpenGL ES像素着色器教程


原文地址:http://www.raywenderlich.com/70208/opengl-es-pixel-shaders-tutorial
泰然翻译组:cissyhope。校对:蓝羽。

在这个像素着色器(pixel shaders)教程里,你将学到如何把你的iPhone变成一块全屏的GPU画板。

这意味着你要做一个底层的图形密集型的app,通过有意思的数学公式在屏幕上把每个像素画出来。

为什么要研究这个呢?因为像素着色器除了是计算机图形学里最酷的东西,还在很多领域非常有用:

注意:上面的demo是用WebGL实现的,只有Chrome和Opera能完美支持,至少在写本教程的时候是这样。而且它们都比较耗性能——所以尽量不要一次打开多个tabs同时跑。

你要写的着色器没有上面的这么复杂,不过要是你熟悉OpenGL ES,就能通过这些练习获得更多。如果你是第一次和这些API打交道,那么请先看看我们在这个主题上的书面或者视频教程。

不要再耽搁了,让我开始荣幸的为你介绍iOS里的像素着色器吧!

注意:教程里提到的“图形密集型(graphics-intensive)”不是在开玩笑。这个app很容易就会让你的iPhone的GPU跑到极限,所以请使用iPhone5或者更高版本。如果你没有这么新的设备,就用iOS模拟器也可以。

准备开始

首先,下载本教程的起始包。去RWTViewController.m文件可以看到GLKViewController的轻量实现,然后编译运行。你的屏幕应该像下面看到的这样:

还没什么特别的地方,但我肯定绿巨人会喜欢。

本教程里,全绿的屏幕代表了你的基础着色器(RWTBase.vshRWTBase.fsh)在正常工作,而你的OpenGL ES代码也都设置正常。在整个教程中,绿色就代表“前进”,红色代表“停下”。

如果任何时候你发现你正盯着一个全红的屏幕,你应该“停下”并验证你的代码,因为你的着色器编译失败,没能正确链接。这套机制是通过在RWTViewController的viewDidLoad方法里将glClearColor()设成红色来实现的。

快速的过一下RWTBase.vsh,这会是你能遇到的最简单的顶点着色器之一。它唯一做的事情就是根据aPosition在x-y平面计算一个点。

顶点属性数组aPosition是通过RWTBaseShader.mRWTBaseShaderQuad传入的,这是一个四元组,存放屏幕的四个顶点(根据OpenGL ES坐标系)。RWTBase.fsh是一个更加简单的片段着色器,将所有的片段不分位置的设为绿色。这就是为什么你会看到一个全绿的屏幕!

现在,让我们更深入一点。。。

像素着色器 vs 顶点/片段着色器

如果你看过一些我们之前的OpenGL ES教程,你可能注意到了我们说顶点着色器是控制顶点的,而片段着色器是控制片段的。基本上,顶点着色器用于画出对象,而片段着色器用于给他们上色。片段着色器可能会生成像素也可能不会,这取决于很多因素,比如深度,透明度以及视口坐标。

所以,如果你渲染根据如下所示四个顶点确定的四元组,会看到什么呢?

假定你没有打开透明度混合或者深度测试,你会看到一个不透明的全屏直角平面

在这些条件下,经过图元光栅化后,每个片段刚好对应了屏幕上的一个像素——不多也不少。因此这个片段着色器会直接对屏幕的每个像素着色,所以被称为像素着色器。

注意:**GL_BLEND**和**GL_DEPTH_TEST**默认是禁用的。你可以在[这里](http://www.khronos.org/opengles/sdk/1.1/docs/man/glEnable.xml)查看**glEnable()**和**glDisable()**都能做些什么,也可以在代码中通过函数**glIsEnabled()**进行查询。

像素着色器101:渐变

你的第一个像素着色器是一个计算线性渐变的简单课程。

注意:为了节省篇幅,将重点放在本教程的算法和方程上,**floats**的全局GLSL **precision(精度)**值被定义为**highp**。

官方的[OpenGL ES iOS编程指南](https://developer.apple.com/library/ios/documentation/3ddrawing/conceptual/opengles_programmingguide/BestPracticesforShaders/BestPracticesforShaders.html)里有一小节介绍了应该如何选择精度,还有一篇[iOS设备兼容性说明](https://developer.apple.com/library/ios/documentation/DeviceInformation/Reference/iOSDeviceCompatibility/OpenGLESPlatforms/OpenGLESPlatforms.html),当你之后想做优化时可以参考。

记住,对于iPhone5全屏画面而言,每个片段着色器每帧都会被调用**727,040**(640*1136)次!

像素着色器背后的魔法就藏在gl_FragCoord里。这个片段独有的变量存放了当前片段相对窗口的坐标。

对于一个普通的片段着色器,“这个值是从顶点生成片段之后对图元进行固定函数插值的结果”。对于像素着色器而言,你只需要知道这个变量的xy值刚好唯一对应屏幕上的一个像素。

打开RWTGradient.fsh,在precision的下方加上:

// Uniforms
uniform vec2 uResolution;

uResolution来自RWTViewController.mglkView:drawInRect:rect变量(也就是包含你视图的矩形)。

RWTBaseShader.m里的uResolution用于处理rect的宽高,并在renderInRect:atTime:方法里将它们赋值给了对应的GLSL常量。也就是说uResolution存放了你屏幕的x-y分辨率。

你可以通过除法gl_FragCoord.xy/uResolution来把你的像素坐标转化到0.0 ≤ xy ≤ 1.0这个范围,这在很多时候能大大简化像素着色器的计算。这也是gl_FragColor的绝佳取值范围,现在让我们来看一些渐变!

RWTGradient.fshmain(void)里增加如下几行:

vec2 position = gl_FragCoord.xy/uResolution;
float gradient = position.x;
gl_FragColor = vec4(0., gradient, 0., 1.);

然后把程序用的片段着色器从RWTBase变到RWTGradient,在RWTViewController.m里将以下代码:

self.shader = [[RWTBaseShader alloc] initWithVertexShader:@"RWTBase" fragmentShader:@"RWTBase"];

改为:

self.shader = [[RWTBaseShader alloc] initWithVertexShader:@"RWTBase" fragmentShader:@"RWTGradient"];

编译运行!你的屏幕会从左到右显示很棒的由黑变绿的渐变效果:

很酷是不是?要想让画面从下到上渐变的话,将RWTGradient.fsh里的

float gradient = position.x;

改成:

float gradient = position.y;

再次编译运行就能看到在新方向的渐变。。。

现在给你个挑战!能在着色器里只改一行代码来实现如下截图效果吗?

提示:记住positiongl_FragColor的取值范围都是从 0.0到1.0。

对角渐变的答案是:

float gradient = (position.x+position.y)/2.;

如果你做对了,恭喜你!如果没有,那请先复习一下本段再继续往下看。:]

像素着色器几何学

在本段中,你将学到如何利用数学知识绘制简单图形,我们将从2D的圆盘圆环开始,到3D球体结束。

几何学:2D圆盘

打开RWTSphere.fsh,在precision下方加入以下代码:

// Uniforms
uniform vec2 uResolution;

增加的是和前一段落里相同的常量,已经可以满足你生成静态几何图形的要求。要创建一个圆盘,在main(void)里增加如下代码:

// 1
vec2 center = vec2(uResolution.x/2., uResolution.y/2.);

// 2
float radius = uResolution.x/2.;

// 3
vec2 position = gl_FragCoord.xy - center;

// 4
if (length(position) > radius) {
  gl_FragColor = vec4(vec3(0.), 1.);
} else {
  gl_FragColor = vec4(vec3(1.), 1.);
}

这儿用到了一些数学知识,来看看究竟发生了什么:

  1. 圆盘的center会处于你屏幕的正中央。
  2. 圆盘的radius会是你屏幕宽度的一半。
  3. position是当前像素的坐标相对圆盘圆心的偏移值。可以想象成是一个向量从圆盘圆心指向当前位置。
  4. length()用于计算向量长度,在这个例子里长度是根据勾股定理√(position.x²+position.y²)来计算的。

    A.如果结果比radius大,说明当前像素在圆盘区域外,那么就染成黑色。

    B.否则,说明当前像素在圆盘区域内,染成白色。
    作为对这个行为的补充说明,可以参看圆形方程(x-a)²+(y-b)² = r²。注意r是半径,ab是圆心,而xy是圆上所有点集。

圆盘是平面上被圆形围住的一块区域,上面的if-else语句会准确的把它画出来!
在你编译运行之前,在RWTViewController.m里把程序的片段着色器改成RWTSphere

self.shader = [[RWTBaseShader alloc] initWithVertexShader:@"RWTBase" fragmentShader:@"RWTSphere"];

现在编译运行。你的屏幕应该显示一个在黑色背景上的实心白色圆盘。这并不是最创新的设计,但却是我们的起点。

你可以根据圆盘的属性随意修改代码,看看会对渲染结果产生什么影响。作为额外的挑战,试试能不能画出如下圆环呢:

提示:试着根据radius创建一个叫thickness的新变量,并在if-else条件中使用。

细圆环的解决方案是:

vec2 center = vec2(uResolution.x/2., uResolution.y/2.);
float radius = uResolution.x/2.;
vec2 position = gl_FragCoord.xy - center;
float thickness = radius/50.;

if ((length(position) > radius) || (length(position) < radius-thickness))   gl_FragColor = vec4(vec3(0.), 1.);
} else {
  gl_FragColor = vec4(vec3(1.), 1.);
}

如果你刚刚尝试了这个挑战,或者修改了GLSL代码,现在请改回显示白色实心圆盘的代码(同时为你的好奇心喝彩)。

用以下代码替换你的if-else条件:

if (length(position) > radius) {
  discard;
}

gl_FragColor = vec4(vec3(1.), 1.);

亲爱的读者,请允许我为你介绍discard。这是一个片段独有的关键字,告诉OpenGL ES丢弃当前片段,并在渲染管道后面的阶段中也忽略它。编译运行可以看到以下画面:

在像素着色器的术语里,diacard返回一个不写到屏幕的空白像素。因此glClearColor()决定了屏幕这个位置绘制的内容。

从这里开始,如果你看到亮红色的像素,说明discard正在正常工作。但还是要注意全屏红色的情况,这说明代码中有错误。

几何学:3D球体

现在是时候来点新玩意将单调的2D圆盘转换成3D球体了,要做到这一点你需要引入深度。

在一个典型的顶点+片段着色器程序中,这可能很简单。顶点着色器可以处理3D几何输入,并将其和其他必要信息一起传给片段着色器。但是,对于像素着色器而言,你只有一个2D平面可供“绘制”,所以需要通过推导z值来模拟深度

在几段内容之前,你通过将圆内所有像素着色创建了一个圆盘,这个圆是由以下方程定义的:

(x-a)²+(y-b)² = r²

要将它扩展成球体方程非常简单,就像这样:

(x-a)²+(y-b)²+(z-c)² = r²

c是球体在z轴上的中心。既然你是在圆心ab的位置建立的2D坐标系,而你的新球体中心又将处在z轴的原点上,那么这个方程可以简化成:

x²+y²+z² = r²

根据方程解出z来:

z² = √(r²-x²-y²)

这样你就根据它们各自独一无二的位置,为所有的片段推推出了z值!足够幸运的是,在GLSL中这很容易通过代码计算。将如下代码加到RWTSphere.fsh中,就放在gl_FragColor的前面。

float z = sqrt(radius*radius - position.x*position.x - position.y*position.y);
z /= radius;

第一行按照前面推导的方程计算z,第二行将这个值跟球体radius做除法,使得取值范围变到0.01.0之间。

为了将球体深度可视化,将当前gl_FragColor一行改为:

gl_FragColor = vec4(vec3(z), 1.);

编译运行就可以看到原本平面的圆盘现在多了第三维的效果。

因为z轴正方向是从屏幕向外指向观测者的,球体离我们最近的部分是白色(中心),而最远的部分是黑色(边缘)。

自然的,在两者中间的点形成了平滑灰色的渐变。这段代码最快速简单的可视化了深度,但是忽略了球体的xy值。如果这个图形旋转起来,或者和其他对象放在一起,你没法分辨它的上下左右。

将这行:

z /= radius;

替换为:

vec3 normal = normalize(vec3(position.x, position.y, z));

引入法线(normals)的概念,可以将方位也在3D空间可视化。在这个例子中,法线是与你的球体表面垂直的向量。对于任意一点,法线确定了这一点朝向的方向。

在这个球体的例子里,为每个点计算法线是很简单的。我们已经有了向量(position)从球体的圆心指向当前点,也知道它的z值。这个向量的方向和点的朝向,也就是法线方向是一致的。

如果你学习过我们以前的OpenGL ES教程,就知道为了简化后续计算(特别是光照),我们通常会normalize()向量。

被归一化(normalized)后,它的取值范围会落在-1.0 ≤ n ≤ 1.0,而像素颜色通道的取值范围是0.0 ≤ c ≤ 1.0。为了更好的将你球体的法线可视化,我们从nc作如下转换:

-1.0 ≤ n ≤ 1.0
(-1.0+1.0) ≤ (n+1.0) ≤ (1.0+1.0)
0.0 ≤ (n+1.0) ≤ 2.0
0.0/2.0 ≤ (n+1.0)/2.0 ≤ 2.0/2.0
0.0 ≤ (n+1.0)/2.0 ≤ 1.0
0.0 ≤ c ≤ 1.0
c = (n+1.0)/2.0

好啦!就这么简单。现在将这一行:

gl_FragColor = vec4(vec3(z), 1.);

替换成:

gl_FragColor = vec4((normal+1.)/2., 1.);

然后编译运行。准备好迎接圆形彩虹的视觉盛宴:

可能第一眼看上去比较晕,特别是和前一个平滑球体比起来,但是在这些颜色里隐藏了很多有价值的信息。。。

你现在看到的其实是球体的法线图(normal map)。在法线图里,rgb的颜色代表了表面法线实际的xyz坐标。见下图:

圈出来的点的rgb值分别为:

p0c = (0.50, 0.50, 1.00)
p1c = (0.50, 1.00, 0.53)
p2c = (1.00, 0.50, 0.53)
p3c = (0.50, 0.00, 0.53)
p4c = (0.00, 0.50, 0.53)

之前你从法线向量n转换得到了颜色c。现在利用逆推公式n = (c*2.0)-1.0,这些颜色又能映射回特定的法线:

p0n = (0.00, 0.00, 1.00)
p1n = (0.00, 1.00, 0.06)
p2n = (1.00, 0.00, 0.06)
p3n = (0.00, -1.00, 0.06)
p4n = (-1.00, 0.00, 0.06)

如果用箭头表示,看起来大概像这样:

现在,对于你的球体在3D空间的朝向应该再没有歧义了。更进一步,现在还能给对象加上合适的光照!

RWTSphere.fshmain(void)里加上以下代码:

// 常量
const vec3 cLight = normalize(vec3(.5, .5, 1.));

这个常量定义了将照亮你的球体的虚拟光源的朝向。在这个例子里,光源从右上角射向屏幕。

然后将以下代码:

gl_FragColor = vec4((normal+1.)/2., 1.);

替换成:

float diffuse = max(0., dot(normal, cLight));

gl_FragColor = vec4(vec3(diffuse), 1.);

你可能看出来了,这是Phong反射模型漫反射(diffuse)成分的简化版。编译运行就可以看到被很好的照亮的球体!

注意:如果你想了解更多关于Phong光照模型的知识,可参考[环境光(Ambient)](http://www.raywenderlich.com/70532/video-tutorial-beginner-opengl-es-glkit-part-7-ambient-lighting),[漫反射(Diffuse)](http://www.raywenderlich.com/70548/video-tutorial-beginner-opengl-es-glkit-part-8-diffuse-lighting),[镜面反射(Specular)](http://www.raywenderlich.com/70648/video-tutorial-beginner-opengl-es-glkit-part-9-specular-lighting)的视频教程【仅对订阅者开放】。

在二维画布绘制三维对象?只使用数学知识?一个像素一个像素的画?哇你做到了!

现在是时候小小的休息一下,好让你沐浴在胜利的光辉中。。。同时也清空你的大脑,因为亲爱的读者,你才刚上路呢。

像素着色器程序生成纹理:Perlin噪声

在这个部分你将学到的知识包括:纹理图元,伪随机数生成器以及基于时间的函数——最后通过它们你能实现一个基本的噪声着色器,其灵感来源于Perlin噪声

Perlin噪声背后的数学知识对这篇教程而言可能太深了一点,而且完整的实现因为过于复杂也很难跑到30帧。

例子里这个着色器虽然基本,但也还是会涵盖各种噪声的基础知识(在此特别鸣谢Hugo EliasToby Schachman提供的模块解释/示例)。

Ken Perlin在1981年为电影TRON设计了Perlin噪声,这是计算机图形学发展历史上,最具开创性的基础算法之一。

它可以模拟自然元素中的伪随机模式,比如云彩和火焰。它在现代CGI中是如此的无处不在,以至于Ken Perlin最后因为这项技术,及其对电影工业的贡献而获得了奥斯卡技术成就奖

奖项本身就很好的解释了Perlin噪声的要点:

“颁给设计了Perlin噪声的Ken Perlin,这项技术被用在电影特效中,在计算机生成的表面上显示自然纹理。Perlin噪声的出现,帮助了计算机图形学艺术家更好的在电影工业特效中模拟复杂的自然现象。”

云:噪声在x轴和z轴上变换

火焰:噪声在x轴和y轴缩放,在z轴变换

是的,这看起来很复杂。。。但是你将从最基础的部分开始实现。

首先你要熟悉时间输入以及数学函数。

程序化纹理:时间

打开RWTNoise.fsh,在precision highp float;下面增加以下代码:

// Uniforms
uniform vec2 uResolution;
uniform float uTime;

你应该已经很熟悉uResolution了,但是uTime还是第一次见到。uTime来自GLKViewController的派生类,也就是RWTViewController.mtimeSinceFirstResume属性(即:从视图控制器首次恢复更新事件以来流逝的时间)。

uTimeRWTBaseShader.m里处理这个时间间隔,并在方法renderInRect:atTime:被赋值给对应GLSL的uniform,也就是说,uTime中存放了你app的流逝时间,以秒为单位。

想要看到uTime起作用,就在RWTNoise.fshmain(void):里加入以下代码:

float t = uTime/2.;
if (t>1.) {
  t -= floor(t);
}

gl_FragColor = vec4(vec3(t), 1.);

这个简单算法会让你的屏幕重复从黑到白的淡入效果。

变量t是流逝时间的一半,需要被转换到颜色的取值范围0.01.0。函数floor()返回最接近并小于或等于t的整数,将t减掉这个数就能符合要求。

例如,uTime = 5.50:t = 0.75,你的屏幕会75%的白。

t = 2.75
floor(t) = 2.00
t = t - floor(t) = 0.75

在你编译运行之前,记得在RWTViewController.m中把程序的片段着色器设成RWTNoise

self.shader = [[RWTBaseShader alloc] initWithVertexShader:@"RWTBase" fragmentShader:@"RWTNoise"];

现在可以编译运行来看你的简单动画!

你也可以通过把if语句改为以下这行代码来简化你的实现:

t = fract(t);

fract()返回t的小数部分,这个值是通过t - floor(t)来计算的。这样看上去简单多了。现在你有了一个简单动画在工作,是时候来制造一点噪音(指的是Perline噪音)了。

程序化纹理:“随机”噪声

fract()是片段着色器编码中的一个基本函数,它保证所有的值都在0.01.0之间,你会用它来做一个伪随机数生成器(PRNG),以模拟白噪声图像。

Perlin噪声模拟自然现象(例如木纹,大理石),而PRNG生成的值就刚好适用,因为它足够随机,可以看上去很自然,但是背后实际有数学函数来保证微妙的模式(例如相同的种子输入每次会生成相同的噪声输出)。

受控的混沌是程序化纹理图元的关键!

注意:计算机随机性是一个引人入胜的主题,可以很容易展开成几十篇教程以及扩展的论坛讨论。Objective-C里的**[arc4random()](https://developer.apple.com/library/mac/documentation/Darwin/Reference/Manpages/man3/arc4random.3.html)**对iOS开发者而言可是个奢侈品。你可以从[NSHipster](http://nshipster.com/random/),也就是Mattt Thompson这里了解更多。正如他优雅的总结:“一切通过随机性考验的,都只不过是一段隐藏的因果链”。

你要写的PRNG主要基于正弦波形,因为正弦波形的循环性对基于时间的输入刚好适用。而且正弦波形也很容易获得,直接调用sin()就好。

它也很易于分析。大部分其他的GLSL PRNG要么就是很伟大,但是无比复杂,要么就是简单,但是不可靠

首先,很快的直观回顾下正弦波

你可能已经很熟悉振幅A和波长λ了。不过如果没有的话也不用太担心,毕竟我们是要生成随机噪声,不是平滑波形。

对于一个标准正弦波而言,从波峰到波谷,振幅取值范围是-1.01.0,波长等于(频率为1)。

在上图中,你是从“前方”观察正弦波的,但是如果你从“上方”观察,将波峰设成白色,波谷设成黑色,就可以利用波峰和波谷来绘制平滑灰度渐变。

打开RWTNoise.fsh,将main(void)的内容替换为:

vec2 position = gl_FragCoord.xy/uResolution.xy;

float pi = 3.14159265359;
float wave = sin(2.\*pi\*position.x);
wave = (wave+1.)/2.;

gl_FragColor = vec4(vec3(wave), 1.);

记住sin(2π) = 0, 对于当前像素而言,相当于你是将与其沿着x轴的小数部分相乘。这样屏幕的最左端是正弦波的左边界,而最右端是正弦波的右边界。

同时还要记住sin的输出是在-1到1之间的,所以需要将结果加1再除以2,这就将输出范围控制在了0到1之间。

编译运行可以看到一个平滑的正弦波渐变,包含一个波峰和一个波谷:

将当前渐变转换成之前那样的图表则看起来像是这样:

现在,通过增加频率来把波长变短,并将屏幕y轴的坐标引入。

wave的计算改为:

float wave = sin(4.\*2.\*pi\*(position.x+position.y));

编译运行。你可以看到你的新波形不仅沿着屏幕对角线分布,而且还包含了更多的波峰和波谷(新的频率是4)。。

到目前为止,你着色器中的方程产生的是整洁、可预测的结果,并形成了有序的波纹。但我们的目标是无序而不是有序,所以现在要把局面打破一点。当然,我们是理智、可控的打破,不是砸向瓷器店那样的破坏。

将以下代码:

float wave = sin(4.\*2.\*pi\*(position.x+position.y));
wave = (wave+1.)/2.;

替换为:

float wave = fract(sin(16.\*2.\*pi\*(position.x+position.y)));

编译运行。你刚作的修改只是增加波形的频率,并用fract()函数来给渐变带来更陡的边缘。同时不再在不同的取值范围之间进行转换,这也给混乱程度加了一点儿料。

着色器现在生成的纹样还是可预测的,所以我们再推上一把。

wave的计算改为:

float wave = fract(10000.\*sin(16.\*(position.x+position.y)));

现在编译运行,可以看到胡椒和盐被洒落的效果。

乘数10000很适合产生伪随机数,而且通过以下表格,可以很快的应用到正弦波上

Angle [sin](http://www.opengroup.org/onlinepubs/009695399/functions/sin.html)(a)
1.0   .0174
2.0   .0349
3.0   .0523
4.0   .0698
5.0   .0872
6.0   .1045
7.0   .1219
8.0   .1392
9.0   .1564
10.0  .1736

观察小数第二位的这一串数字:

1, 3, 5, 6, 8, 0, 2, 3, 5, 7

再观察小数第四位的这一串数字:

4, 9, 3, 8, 2, 5, 9, 2, 4, 6

跟第二串数比起来,第一串数的模式更为明显。虽然不一定永远正确,但低一些的小数位是我们挖掘伪随机数序列的一个很好起点。

另外很大的数还可能有非故意的精度损失/溢出错误,这对随机效果更有帮助。

这时候你可能还能在屏幕上看出一点对角波纹的痕迹。如果看不出来,可能需要去找下你的验光师:)

这个淡淡的波纹是因为你给position.xposition.y同样的权重造成的。给每个轴增加一个不同的乘数就可以驱散这个痕迹,比如这样:

float wave = fract(10000.\*sin(128.\*position.x+1024.\*position.y));

是时候收拾一下了!在main(void)的上方增加如下函数randomNoise(vec2 p)

float randomNoise(vec2 p) {
  return fract(6791.*sin(47.*p.x+p.y*9973.));
}

这个PRNG的随机性主要取决于你对于乘数的选择。

以上的参数是我从质数表里选出来的,你也可以这样。如果你自行选择,我建议p.x用一个小一点的值,而p.ysin()用大一点的值。

接下来使用新的randomNoise函数重构你的着色器,将main(void)的内容用以下代码替换:

vec2 position = gl_FragCoord.xy/uResolution.xy;
float n = randomNoise(position);
gl_FragColor = vec4(vec3(n), 1.);

好啦!你现在就有个简单的基于sin函数的PRNG以用来生成2D噪声了。编译运行,然后值得歇一会来庆祝一下。

程序化纹理:方形网格

在跟3D球体打交道的时候,归一化的向量让方程变得简单了很多,对程序化纹理也一样,特别是噪声。类似平滑和插值的函数,如果是在矩形网格上作用就会简化很多。打开RWTNoise.fsh并将计算position的代码改为:

vec2 position = gl_FragCoord.xy/uResolution.xx;

这保证了position的单元尺寸与你的屏幕宽度(uResolution.x)一致。

在下一行增加如下if语句:

if ((position.x>1.) || (position.y>1.)) {
  discard;
}

热烈欢迎discard回到你的代码中,然后编译运行,可以看到渲染出如下图像:

这个简单的方形就是你新的1x1像素着色器视口。

既然2D噪声可以沿着x和y无限扩展,那么如果你把噪声输入替换为以下任意一行:

float n = randomNoise(position-1.);
float n = randomNoise(position+1.);

就能看到:

对于任何一个基于噪声的程序化纹理,太多噪声和噪声不够是完全不同的两个概念。幸运的是,将你的网格分块就能控制这种局面。

在你的main(void)增加如下代码,就在n前面:

float tiles = 2.;
position = floor(position*tiles);

然后编译运行!你可以看到如下的2*2方格:

乍看上去可能有点迷惑,解释如下:

floor(position*tiles)会将任何值截取为小于或等于position*tiles的最接近整数。在两个方向上的取值都是在范围(0.0, 0.0)(2.0, 2.0)中。

如果没有floor(),这个范围会平滑连续,并且每个片段位置都会给noise()不同的种子。

然而floor()生成了一个阶梯范围,就像在图中那样,在每个整数处停下。因此两个整数之间的每个position值都会被截成相同值,再作为noise()的种子,从而生成了整齐的网格图。

网格瓦片个数选择的依据取决于你想要生成哪种类型的纹理效果。Perlin噪声引入了很多网格来模拟噪声模式,并且每一个的网格数都不同。

如果瓦片太多,就会生成斑驳的重复纹样。例如tiles = 128时,看起来就大概是这样:

程序化纹理:平滑噪声

到此刻为止,你的噪声纹理,有一点,太噪声了。如果你只是想模拟一台没信号的老式学校电视机或者MissingNo还行。

但是如果想要平滑一些的纹理怎么办呢?那么你可以使用平滑函数。准备好变速齿轮,开始图形处理课程101吧。

在2D图像处理中,像素和它们的邻居有一定的连通性。一个八连像素有八个邻居像素围绕着自己;四个与边相邻,四个与顶点相连。

这个概念也被称为Moore近邻,如图所示,CC就是我们说的中心像素:

注意:想学习更多关于Moore近邻和图像处理的知识,请参看我们的[iOS教程系列之图像处理](http://www.raywenderlich.com/69855/image-processing-in-ios-part-1-raw-bitmap-modification)

一种常见的图像平滑操作是减弱图像的边缘频率,生成一份模糊/涂抹过的原图拷贝。这对你的矩形网格很有效,可以减少相邻瓦片之间的明显剧烈变化。

例如,如果白色瓦片被黑色瓦片包围,则平滑函数会调整瓦片颜色为浅灰。使用像下图这样的卷积),平滑函数会对每个像素生效:

这是一个3x3的近邻平均滤波器,只是简单的通过对8个邻居求平均(使用相同权重)来进行平滑。要生成上述图像,需要这样的步骤:

p = 0.1
p’ = (0.3+0.9+0.5+0.7+0.2+0.8+0.4+0.6+0.1) / 9
p’ = 4.5 / 9
p’ = 0.5

这不是最有意思的滤波器,但是最简单有效,易于实现!打开RWTNoise.fsh,并在main(void)上方增加如下函数:

float smoothNoise(vec2 p) {
  vec2 nn = vec2(p.x, p.y+1.);
  vec2 ne = vec2(p.x+1., p.y+1.);
  vec2 ee = vec2(p.x+1., p.y);
  vec2 se = vec2(p.x+1., p.y-1.);
  vec2 ss = vec2(p.x, p.y-1.);
  vec2 sw = vec2(p.x-1., p.y-1.);
  vec2 ww = vec2(p.x-1., p.y);
  vec2 nw = vec2(p.x-1., p.y+1.);
  vec2 cc = vec2(p.x, p.y);

  float sum = 0.;
  sum += randomNoise(nn);
  sum += randomNoise(ne);
  sum += randomNoise(ee);
  sum += randomNoise(se);
  sum += randomNoise(ss);
  sum += randomNoise(sw);
  sum += randomNoise(ww);
  sum += randomNoise(nw);
  sum += randomNoise(cc);
  sum /= 9.;

  return sum;
}

有点长,但是很直接。因为你的网格被分成了1x1的瓦片,因此不管在哪个方向±1.,或者组合起来,都能让你得到一个相邻瓦片。片段是在GPU中被并行批处理的,所以在程序化纹理中,要知道相邻片段的值,只能实时计算。

修改main(void)以拥有128个瓦片,并通过smoothNoise(position)来计算n。经过这些修改,你的main(void)函数看起来应该像这样:

void main(void) {
    vec2 position = gl_FragCoord.xy/uResolution.xx;
    float tiles = 128.;
    position = floor(position*tiles);
    float n = smoothNoise(position);
    gl_FragColor = vec4(vec3(n), 1.);
}

编译运行!你被平滑函数击中了:P

每个像素都单独调用9次randomNoise()对于GPU而言是个沉重负担。你可以研究8连平滑函数,但是4连,也叫做Von Neumann近邻已经可以生成很好的平滑函数了。

近邻平均的模糊效果有点儿粗糙,把你淳朴的噪声变成了灰色的泥浆。为了保留多一点原始的强度,执行如下卷积核:

这个新滤波器显著减少了近邻影响,原始中心像素在最后结果中占了50%,另外50%来自4个通过边相连的像素。对于上图而言,结果变成:

p = 0.1
p’ = (((0.3+0.5+0.2+0.4) / 4) / 2) + (0.1 / 2)
p’ = 0.175 + 0.050
p’ = 0.225

快速挑战!看看你能不能在smoothNoise(vec2 p)中实现这个半邻平均滤波器。

提示:记住要去掉那些不用的相邻像素!你的GPU会因此感谢你,并回报以更快的渲染速度和更少的抱怨。

平滑噪声滤波器的答案是:

float smoothNoise(vec2 p) {
  vec2 nn = vec2(p.x, p.y+1.);
  vec2 ee = vec2(p.x+1., p.y);
  vec2 ss = vec2(p.x, p.y-1.);
  vec2 ww = vec2(p.x-1., p.y);
  vec2 cc = vec2(p.x, p.y);

  float sum = 0.;
  sum += randomNoise(nn)/8.;
  sum += randomNoise(ee)/8.;
  sum += randomNoise(ss)/8.;
  sum += randomNoise(ww)/8.;
  sum += randomNoise(cc)/2.;

  return sum;
}

如果你没做出来,看看答案,然后把自己的smoothNoise方法替换掉。将你的tiles减为8.,然后编译运行。

你的纹理看起来更加自然,瓦片之间的过渡也更加平滑。比较上图(平滑噪声)和下图(随机噪声),感受一下平滑函数的效果。

到目前为止都干得不错:]

程序化纹理:插值噪声

噪声着色器的下一步是通过双线性插值,即2D网格上的简单线性插值,来处理瓦片的明显边缘。

为了便于理解,下图以你的噪声函数生成的2x2网格为例,标出了双线性插值需要的采样点:

通过对p点所处块的顶点取值并加以权重,瓦片就可以和相邻块混合到一起。因为每个瓦片都是1x1的单位瓦片,Q点可以通过这样来取样噪声:

Q11 = smoothNoise(0.0, 0.0);
Q12 = smoothNoise(0.0, 1.0);
Q21 = smoothNoise(1.0, 0.0);
Q22 = smoothNoise(1.0, 1.0);

在代码里,你可以对p组合调用floor()ceil()函数来实现,在RWTNoise.fshmain(void)上增加如下函数:

float interpolatedNoise(vec2 p) {
  float q11 = smoothNoise(vec2(floor(p.x), floor(p.y)));
  float q12 = smoothNoise(vec2(floor(p.x), ceil(p.y)));
  float q21 = smoothNoise(vec2(ceil(p.x), floor(p.y)));
  float q22 = smoothNoise(vec2(ceil(p.x), ceil(p.y)));

  // 计算 R 的值
  // 返回 P 的值
}

GLSL已经包含了一个线性插值函数mix()

你将用它来计算R1R2,对于在y轴上处于相同高度的两个Q点,加入fract(p.x)作为权重。将以下代码加到interpolatedNoise(vec2 p)的最后:

float r1 = mix(q11, q21, fract(p.x));
float r2 = mix(q12, q22, fract(p.x));

最后,使用mix()对两个R值进行插值,并用fract(p.y)作为浮点权重。你的函数看上去应该是这样:

float interpolatedNoise(vec2 p) {
  float q11 = smoothNoise(vec2(floor(p.x), floor(p.y)));
  float q12 = smoothNoise(vec2(floor(p.x), ceil(p.y)));
  float q21 = smoothNoise(vec2(ceil(p.x), floor(p.y)));
  float q22 = smoothNoise(vec2(ceil(p.x), ceil(p.y)));

  float r1 = mix(q11, q21, fract(p.x));
  float r2 = mix(q12, q22, fract(p.x));

  return mix (r1, r2, fract(p.y));
}

因为你的新函数需要使用浮点权重来平滑,并在采样时用到了floor()ceil(),你需要把main(void)里的floor()去掉。

将以下代码:

float tiles = 8.;
position = floor(position*tiles);
float n = smoothNoise(position);

替换为:

float tiles = 8.;
position *= tiles;
float n = interpolatedNoise(position);

编译运行。明显的瓦片边缘消失了。。。

。。。但还是有明显的“星状”图案,这也是意料之中的事情。

我们通过smoothstep函数来处理掉这个图案。smoothstep()是一个使用三次插值的函数,有着漂亮的曲线,比简单线性插值看上去好很多。

“Smoothstep就像魔法盐一样,你可以把它洒在任何东西上面以让其看起来更好。”——Jari Komppa

interpolatedNoise(vec2 p)的最前面加入以下代码:

vec2 s = smoothstep(0., 1., fract(p));

现在就能使用被smoothstep处理过的s作为mix()函数的权重了,像这样:

float r1 = mix(q11, q21, s.x);
float r2 = mix(q12, q22, s.x);

return mix (r1, r2, s.y);

编译运行,星星就消失了!

星星是不见了,但是还是能看有一点像迷宫的图案。这是因为你的方形网格分成了8x8块,把tiles减少到4.,再编译运行!

好多了。

你的噪声函数在网格边缘还是有点粗糙,不过已经可以作为浓烟或者模糊的影子的纹理元使用了。

程序化纹理:移动的噪声

最后一步!希望你还没忘记uTime,因为现在是时候让你的噪声动起来,只用在main(void)中,在对n赋值前加入一行:

position += uTime;

编译运行。

你的噪声纹理会朝着左下角移动,但是实际上是你的网格在朝着右上角(+x,+y的方向)移动。记住2D噪声是在所有方向无限扩展的,所以你的动画永远都是无缝的。

像素着色器绘制的月球

猜想:球体+噪声=月亮?你马上就能得到答案!

在这个教程的最后,你将把你的球体着色器和噪声着色器,在RWTMoon.fsh里合并成一个简单的月球着色器。你已经掌握了所有的信息,是时候接受这个伟大挑战了。

提示:你的噪声瓦片数现在要根据球体半径来决定,所以将以下代码:

float tiles = 4.;
position *= tiles;

替换为简单的:

position /= radius;

同时我建议你通过这个函数来重构部分代码:

float diffuseSphere(vec2 p, float r) {
}

狼人出没,请小心,答案是:

//  RWTMoon.fsh
//
// Precision
precision highp float;

// Uniforms
uniform vec2 uResolution;
uniform float uTime;

// Constants
const vec3 cLight = normalize(vec3(.5, .5, 1.));

float randomNoise(vec2 p) {
  return fract(6791.*sin(47.*p.x+p.y*9973.));
}

float smoothNoise(vec2 p) {
  vec2 nn = vec2(p.x, p.y+1.);
  vec2 ee = vec2(p.x+1., p.y);
  vec2 ss = vec2(p.x, p.y-1.);
  vec2 ww = vec2(p.x-1., p.y);
  vec2 cc = vec2(p.x, p.y);

  float sum = 0.;
  sum += randomNoise(nn)/8.;
  sum += randomNoise(ee)/8.;
  sum += randomNoise(ss)/8.;
  sum += randomNoise(ww)/8.;
  sum += randomNoise(cc)/2.;

  return sum;
}

float interpolatedNoise(vec2 p) {
  vec2 s = smoothstep(0., 1., fract(p));

  float q11 = smoothNoise(vec2(floor(p.x), floor(p.y)));
  float q12 = smoothNoise(vec2(floor(p.x), ceil(p.y)));
  float q21 = smoothNoise(vec2(ceil(p.x), floor(p.y)));
  float q22 = smoothNoise(vec2(ceil(p.x), ceil(p.y)));

  float r1 = mix(q11, q21, s.x);
  float r2 = mix(q12, q22, s.x);

  return mix (r1, r2, s.y);
}

float diffuseSphere(vec2 p, float r) {
  float z = sqrt(r*r - p.x*p.x - p.y*p.y);
  vec3 normal = normalize(vec3(p.x, p.y, z));
  float diffuse = max(0., dot(normal, cLight));
  return diffuse;
}

void main(void) {
  vec2 center = vec2(uResolution.x/2., uResolution.y/2.);
  float radius = uResolution.x/2.;
  vec2 position = gl_FragCoord.xy - center;

  if (length(position) > radius) {
    discard;
  }

  // Diffuse
  float diffuse = diffuseSphere(position, radius);

  // Noise
  position /= radius;
  position += uTime;
  float noise = interpolatedNoise(position);

  gl_FragColor = vec4(vec3(diffuse*noise), 1.);
}

记住在RWTViewController.m里把代码的片段着色器改成RWTMoon

self.shader = [[RWTBaseShader alloc] initWithVertexShader:@"RWTBase" fragmentShader:@"RWTMoon"];

做完这一步之后,可以随意的把你的glClearColor()改成更适合这个场景的颜色(个人选择xkcd的午夜紫):

glClearColor(.16f, 0.f, .22f, 1.f);

编译运行!我肯定Ozzy Osbourne会喜欢这个的。

何去何从?

你可以下载到完整的项目,包括了本篇OpenGL ES像素着色器教程里所有的代码和资源。你也可以在GitHub找到这个代码仓库。

祝贺,你已经对着色器和GPU颇有了解了。这是一篇很不一样并且很难的教程,真心为你的努力鼓掌。

你现在应该懂得如何利用GPU的巨大力量,加上聪明地利用数学就可以创造出有意思的逐像素渲染效果。你也应该理解了GLSL的函数、语法和结构。

本教程并没有包含太多Objective-C的内容,所以回到你的CPU,并看看能不能更酷的操控着色器吧!

试着增加统一的变量来获得触控点,陀螺仪数据,或者话筒输入。浏览器+WebGL可能更强大,但移动设备+OpenGL ES却更有趣:]

从这里开始可以引出多种探索的道路,这里有些建议:

  • 想让你的着色器性能出众?看看Apple的OpenGL ES调适建议(对iPhone 5s特别推荐)
  • 想进一步了解Perlin噪声并完善你的实现?请查看Ken自己的快速介绍或者详细历史
  • 觉得还应该加强下基础?Toby有你想要的
  • 或者,只是或者,你觉得你已经做好进阶的准备了?那么去Shadertoy看看大师作品,如果你也提交了什么内容,给我们留言!

总的来说,我建议你先直接去GLSL沙盒看看那不可思议的画廊。

在那里你可以看到各种水平和用途的着色器,而且这个画廊是被WebGL和OpenGL ES界的一些大牛编辑/维护的。他们是真正闪耀的明星,促使了这篇教程的诞生,并绘制了3D图形学的未来,无比感谢他们!(特别是@mrdoob@iquilezles@alteredq

如果你有任何问题、评论或者建议,欢迎加入下面的讨论!

标签: opengl

?>