OpenGL ES

子龙山人带你学习OpenGL ES 2.0 :纹理贴图

大家好,我是子龙山人。我现在在厦门 Cocos2D-X 团队做游戏引擎开发。

我是一个技术 Geek,我喜欢研究好玩的技术,同时我也是一个 Emacs 党。

欢迎大家一起交流。

我的个人主页:http://zilongshanren.com/


在上一篇文章中,我们介绍了如何绘制一个立方体,里面涉及的知识点有VBO(Vertex Buffer Object)、IBO(Index Buffer Object)和MVP(Modile-View-Projection)变换。

本文将在教程4的基础之上,添加纹理贴图支持。最后,本文会把纹理贴图扩展至3D立方体上面。

基本方法

当我们把一张图片加载到内存里面之后,它是不能直接被GPU绘制出来的,纹理贴图过程如下:

首先,我们为之前的顶点添加纹理坐标属性并传到vertex shader里面去,然后把内存里面的纹理传给GPU,最后,在fragment shader里面通过采样器,就可以根据vertex shader传递过来的纹理坐标把纹理上面的颜色值用插值的方式映射到每一个像素上去。

接下来,让我们看看具体怎么做。

阅读全文»

子龙山人带你学习OpenGL ES 2.0 : 绘制立方体

大家好,我是子龙山人。我现在在厦门 Cocos2D-X 团队做游戏引擎开发。

我是一个技术 Geek,我喜欢研究好玩的技术,同时我也是一个 Emacs 党。

欢迎大家一起交流。

我的个人主页:http://zilongshanren.com/


上篇文章中,我们介绍了VBO索引的使用,使用VBO索引可以有效地减少顶点个数,优化内存,提高程序效率。

本教程将带领大家一起走进3D–绘制一个立方体。其实画立方体本质上和画三角形没什么区别,所有的模型最终都要转换为三角形。

同时,本文还会介绍如何通过修改MVP矩阵来让此立方体不停地旋转。另外,大家还可以动手去修改本教程的示例代码,借此我们可以更加深入地理解OpenGL的normalized device space。

阅读全文»

子龙山人带你学习OpenGL ES 2.0 :使用VBO索引

大家好,我是子龙山人。我现在在厦门 Cocos2D-X 团队做游戏引擎开发。

我是一个技术 Geek,我喜欢研究好玩的技术,同时我也是一个 Emacs 党。

欢迎大家一起交流。

我的个人主页:http://zilongshanren.com/


上一篇文章中,我们介绍了uniform和模型-视图-投影变换,相信大家对于OpenGL ES 2.0应该有一点感觉了。在这篇文章中,我们不再画三角形了,改为画四边形。下篇教程,我们就可以画立方体了,到时候就是真3D了,哈哈。

为什么三角形在OpenGL教程里面这么受欢迎呢?因为在OpenGL的世界里面,所有的几何体都可以用三角形组合出来。我们的四边形也一样,它可以用两个三角形组合出来。

阅读全文»

子龙山人带你学习OpenGL ES 2.0 :初识MVP

大家好,我是子龙山人。我现在在厦门 Cocos2D-X 团队做游戏引擎开发。

我是一个技术 Geek,我喜欢研究好玩的技术,同时我也是一个 Emacs 党。

欢迎大家一起交流。

我的个人主页:http://zilongshanren.com/


上一篇文章中,我在介绍vertex shader的时候挖了一个坑:CC_MVPMatrix。它其实是一个uniform,每一个cocos2d-x预定义的shader都包含有这个uniform, 但是如果你在shader里面不使用这个变量的话,OpenGL底层会把它优化掉。

但是,CC_MVPMatrix是在什么时候设置进来的呢?我在shader里面明明没有看到它,它从哪儿来的?别急,请继续往下读。

初识Uniform

在回答上面几个问题之前,让我们先来介绍一下什么是uniform。简单来说,uniform是shader里面的一种变量,它是由外部程序设置进来的,它不像vertex的attribute,每个顶点都有一份数据。除非你显式地调用glUniformXXX函数来修改这个uniform的值,否则它的值恒定不变。接下来,让我们修改myFragmentShader.frag,给它添加一个新的uniform数据:

阅读全文»

子龙山人带你学习OpenGL ES 2.0 :编写自己的shader

大家好,我是子龙山人。我现在在厦门 Cocos2D-X 团队做游戏引擎开发。

我是一个技术 Geek,我喜欢研究好玩的技术,同时我也是一个 Emacs 党。

欢迎大家一起交流。

Emacs 交流:https://slackin-emacs-cn.herokuapp.com/

Cocos2D-x 交流: Cocos2D-X Github issue system. https://github.com/cocos2d/cocos2d-x/issues/new

我的 Email: guanghui8827@gmail.com

我的个人主页:http://zilongshanren.com/


上篇文章中,我给大家介绍了如何在cocos2d-x里面绘制一个三角形,当时我们使用的是cocos2d-x引擎自带的shader和一些辅助函数。在本文中,我将演示一下如何编写自己的shader,同时,我们还会介绍VBO(顶点缓冲区对象)和VAO(顶点数组对象)的基本用法。

阅读全文»

子龙山人带你学习OpenGL ES 2.0 :你的第一个三角形

大家好,我是子龙山人。我现在在厦门 Cocos2D-X 团队做游戏引擎开发。

我是一个技术 Geek,我喜欢研究好玩的技术,同时我也是一个 Emacs 党。

欢迎大家一起交流。

Emacs 交流:https://slackin-emacs-cn.herokuapp.com/

Cocos2D-x 交流: Cocos2D-X Github issue system. https://github.com/cocos2d/cocos2d-x/issues/new

我的 Email: guanghui8827@gmail.com

我的个人主页:http://zilongshanren.com/

简介

欢迎来到我的 OpenGLES 系列教程,这个系列的教程我打算以时下最流行的开源游戏引擎 Cocos2D-X 为基础来介绍 OpenGLES。 我写作这个系列教程的原因是,一方面对我自己学习 OpenGLES 的一个总结,另一方面也希望通过一些具体的、 容易理解的示例,让大家更好地学习 OpenGLES。

阅读全文»

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

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

OpenGL3.0教程

OpenGL3.0教程

  • OpenGL3.0教程 第一课:新建一个窗口
  • OpenGL3.0教程 第二课: 画第一个三角形
  • OpenGL3.0教程 第三课: 矩阵
  • OpenGL3.0教程 第四课:彩色立方体
  • OpenGL3.0教程 第五课:纹理立方体
  • OpenGL3.0教程 第六课:键盘和鼠标
  • OpenGL3.0教程 第七课:模型加载
  • OpenGL3.0教程 第八课:基础光照模型
  • OpenGL3.0教程 第九课:VBO索引
  • OpenGL3.0教程 第十课:透明
  • OpenGL3.0教程 第十一课:2D文本
  • OpenGL3.0教程 第十二课:OpenGL扩展
  • OpenGL3.0教程 第十三课:法线贴图
  • OpenGL3.0教程 第十四课:渲染到纹理
  • OpenGL3.0教程 第十五课:光照贴图
  • OpenGL3.0教程 第十六课:阴影贴图
  • OpenGL3.0教程

    初学者教程

  • 第一课:新建一个窗口
  • 第二课: 画第一个三角形
  • 第三课: 矩阵
  • 第四课:彩色立方体
  • 第五课:纹理立方体
  • 第六课:键盘和鼠标
  • 第七课:模型加载
  • 第八课:基础光照模型
  • 中级教程

  • 第九课:VBO索引
  • 第十课:透明
  • 第十一课:2D文本
  • 第十二课:OpenGL扩展
  • 第十三课:法线贴图
  • 第十四课:渲染到纹理
  • 第十五课:光照贴图
  • 第十六课:阴影贴图
  • OpenGL3.0教程 第十六课:阴影贴图


    OpenGL3.0教程

    原文链接:http://www.opengl-tutorial.org/intermediate-tutorials/tutorial-12-opengl-extensions/

    原译文链接: https://github.com/cybercser/OpenGL_3_3_Tutorial_Translation/blob/master/Tutorial%2016%20Shadow%20mapping%20opengl-tutorial.org.md

    第十五课中已经学习了如何创建光照贴图。光照贴图可用于静态对象的光照,其阴影效果也很不错,但无法处理运动的对象。

    阴影贴图是目前(截止2012年)最好的生成动态阴影的方法。此法最大的优点是易于实现,缺点是想完全正确地实现不大容易。

    本课首先介绍基本算法,探究其缺陷,然后实现一些优化。由于撰写本文时(2012),阴影贴图技术还在被广泛地研究;我们将提供一些指导,以便你根据自身需要,进一步改善你的阴影贴图。

    基本的阴影贴图

    基本的阴影贴图算法包含两个步骤。首先,从光源的视角将场景渲染一次,只计算每个片断的深度。接着从正常的视角把场景再渲染一次,渲染时要测试当前片断是否位于阴影中。

    “是否在阴影中”的测试实际上非常简单。如果当前采样点比阴影贴图中的同一点离光源更远,那说明场景中有一个物体比当前采样点离光源更近;即当前片断位于阴影中。

    下图可以帮你理解上述原理:

    shadowmapping

    渲染阴影贴图

    本课只考虑平行光——一种位于无限远处,其光线可视为相互平行的光源。故可用正交投影矩阵来渲染阴影贴图。正交投影矩阵和一般的透视投影矩阵差不多,只不过未考虑透视——因此无论距离相机多远,物体的大小看起来都是一样的。

    设置渲染目标和MVP矩阵

    十四课中,大家学习了把场景渲染到纹理,以便稍后从shader中访问的方法。

    这里采用了一幅1024x1024、16位深度的纹理来存储阴影贴图。对于阴影贴图来说,通常16位绰绰有余;你可以自由地试试别的数值。注意,这里采用的是深度纹理,而非深度渲染缓冲区(这个要留到后面进行采样)。

    // The framebuffer, which regroups 0, 1, or more textures, and 0 or 1 depth buffer.
     GLuint FramebufferName = 0;
     glGenFramebuffers(1, &FramebufferName);
     glBindFramebuffer(GL_FRAMEBUFFER, FramebufferName);
    
     // Depth texture. Slower than a depth buffer, but you can sample it later in your shader
     GLuint depthTexture;
     glGenTextures(1, &depthTexture);
     glBindTexture(GL_TEXTURE_2D, depthTexture);
     glTexImage2D(GL_TEXTURE_2D, 0,GL_DEPTH_COMPONENT16, 1024, 1024, 0,GL_DEPTH_COMPONENT, GL_FLOAT, 0);
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
     glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    
     glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthTexture, 0);
    
     glDrawBuffer(GL_NONE); // No color buffer is drawn to. 
    
     // Always check that our framebuffer is ok
     if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
     return false;
    
    

    MVP矩阵用于从光源的视角绘制场景,其计算过程如下:

    • 投影矩阵是正交矩阵,可将整个场景包含到一个AABB(axis-aligned box, 轴向包围盒)里,该包围盒在X、Y、Z轴上的坐标范围分别为(-10,10)、(-10,10)、(-10,20)。这样做是为了让整个场景始终可见,这一点在“再进一步”小节还会讲到。
    • 视图矩阵对场景做了旋转,这样在观察坐标系中,光源的方向就是-Z方向(需要温习[第三课]
    • 模型矩阵可设为任意值。
     glm::vec3 lightInvDir = glm::vec3(0.5f,2,2);
     // Compute the MVP matrix from the light's point of view
     glm::mat4 depthProjectionMatrix = glm::ortho(-10,10,-10,10,-10,20);
     glm::mat4 depthViewMatrix = glm::lookAt(lightInvDir, glm::vec3(0,0,0), glm::vec3(0,1,0));
     glm::mat4 depthModelMatrix = glm::mat4(1.0);
     glm::mat4 depthMVP = depthProjectionMatrix * depthViewMatrix * depthModelMatrix; 
    
     // Send our transformation to the currently bound shader,
     // in the "MVP" uniform
     glUniformMatrix4fv(depthMatrixID, 1, GL_FALSE, &depthMVP[0][0])
    

    Shaders

    这一次渲染中所用的着色器很简单。顶点着色器仅仅简单地计算一下顶点的齐次坐标:

    #version 330 core
    
    // Input vertex data, different for all executions of this shader.
    layout(location = 0) in vec3 vertexPosition_modelspace;
    
    // Values that stay constant for the whole mesh.
    uniform mat4 depthMVP;
    
    void main()
    {
        gl_Position =  depthMVP * vec4(vertexPosition_modelspace,1);
    }
    

    fragment shader同样简单:只需将片断的深度值写到location 0(即写入深度纹理)。

    #version 330 core
    
    // Ouput data
    layout(location = 0) out float fragmentdepth;
    
    void main(){
        // Not really needed, OpenGL does it anyway
        fragmentdepth = gl_FragCoord.z;
    }
    

    渲染阴影贴图比渲染一般的场景要快一倍多,因为只需写入低精度的深度值,不需要同时写深度值和颜色值。显存带宽往往是影响GPU性能的关键因素。

    结果

    渲染出的纹理如下所示:

    DepthTexture

    颜色越深表示z值越小;故墙面的右上角离相机更近。相反地,白色表示z=1(齐次坐标系中的值),离相机十分遥远。

    使用阴影贴图

    基本shader

    现在回到普通的着色器。对于每一个计算出的fragment,都要测试其是否位于阴影贴图之“后”。

    为了做这个测试,需要计算:在创建阴影贴图所用的坐标系中,当前片断的坐标。因此要依次用通常的MVP矩阵和depthMVP矩阵对其做变换。

    不过还需要一些技巧。将depthMVP与顶点坐标相乘得到的是齐次坐标,坐标范围为[-1,1],而纹理采样的取值范围却是[0,1]。

    举个例子,位于屏幕中央的fragment的齐次坐标应该是(0,0);但要对纹理中心进行采样,UV坐标就应该是(0.5,0.5)。

    这个问题可以通过在片断着色器中调整采样坐标来修正,但用下面这个矩阵去乘齐次坐标则更为高效。这个矩阵将坐标除以2(主对角线上[-1,1] -> [-0.5, 0.5]),然后平移(最后一行[-0.5, 0.5] -> [0,1])。

    glm::mat4 biasMatrix(
    0.5, 0.0, 0.0, 0.0,
    0.0, 0.5, 0.0, 0.0,
    0.0, 0.0, 0.5, 0.0,
    0.5, 0.5, 0.5, 1.0
    );
    glm::mat4 depthBiasMVP = biasMatrix*depthMVP;
    

    终于可以写vertex shader了。和之前的差不多,不过这次要输出两个坐标。

    • gl_Position是当前相机所在坐标系下的顶点坐标
    • ShadowCoord是上一个相机(光源)所在坐标系下的顶点坐标
    // Output position of the vertex, in clip space : MVP * position
    gl_Position =  MVP * vec4(vertexPosition_modelspace,1);
    
    // Same, but with the light's view matrix
    ShadowCoord = DepthBiasMVP * vec4(vertexPosition_modelspace,1);
    

    fragment shader就很简单了:

    • texture2D( shadowMap, ShadowCoord.xy ).z 是光源到距离最近的遮挡物之间的距离。
    • ShadowCoord.z是光源和当前片断之间的距离

    ……因此,若当前fragment比最近的遮挡物还远,那意味着这个片断位于(这个最近的遮挡物的)阴影中

    float visibility = 1.0;
    if ( texture2D( shadowMap, ShadowCoord.xy ).z  <  ShadowCoord.z){
    visibility = 0.5;
    }
    

    我们只需把这个原理加到光照计算中。当然,环境光分量无需改动,毕竟这只分量是个为了模拟一些光亮,让即使处在阴影或黑暗中的物体也能显出轮廓来(否则就会是纯黑色)。

    color =
     // Ambiant : simulates indirect lighting
     MaterialAmbiantColor +
     // Diffuse : "color" of the object
     visibility * MaterialDiffuseColor * LightColor * LightPower * cosTheta+
     // Specular : reflective highlight, like a mirror
     visibility * MaterialSpecularColor * LightColor * LightPower * pow(cosAlpha,5);
    

    结果——阴影瑕疵(Shadow acne)

    这是目前的代码渲染的结果。很明显,大体的思想是实现了,不过质量不尽如人意。

    1rstTry-1024x793

    逐一检查图中的问题。代码有两个工程:shadowmaps和shadowmaps_simple,任选一项。simple版的效果和上图一样糟糕,但代码比较容易理解。

    问题

    阴影瑕疵

    最明显的问题就是阴影瑕疵

    ShadowAcne

    这种现象可用下面这张简单的图解释:

    shadow-acne

    通常的“补救措施”是加上一个误差容限(error margin):仅当当前fragment的深度(再次提醒,这里指的是从光源的坐标系得到的深度值)确实比光照贴图像素的深度要大时,才将其判定为阴影。这可以通过添加一个偏差(bias)来办到:

    float bias = 0.005;
    float visibility = 1.0;
    if ( texture2D( shadowMap, ShadowCoord.xy ).z  <  ShadowCoord.z-bias){
    visibility = 0.5;
    }
    

    效果好多了::

    FixedBias-1024x793

    不过,您也许注意到了,由于加入了偏差,墙面与地面之间的瑕疵显得更加明显了。更糟糕的是,0.005的偏差对地面来说太大了,但对曲面来说又太小了:圆柱体和球体上的瑕疵依然可见。

    一个通常的解决方案是根据斜率调整偏差:

    float bias = 0.005*tan(acos(cosTheta)); // cosTheta is dot( n,l ), clamped between 0 and 1
    bias = clamp(bias, 0,0.01);
    

    阴影瑕疵消失了,即使在曲面上也看不到了。

    VariableBias-1024x793

    还有一个技巧,不过这个技巧灵不灵得看具体的几何形状。此技巧只渲染阴影中的背面。这就对厚墙的几何形状提出了硬性要求(请看下一节——阴影悬空(Peter Panning),不过即使有瑕疵,也只会出现在阴影遮蔽下的表面上。【译者注:在迪斯尼经典动画《小飞侠》中,小飞侠彼得·潘的影子和身体分开了,小仙女温蒂又给他缝好了。】

    shadowmapping-backfaces

    渲染阴影贴图时剔除正面的三角形:

        // We don't use bias in the shader, but instead we draw back faces,
        // which are already separated from the front faces by a small distance
        // (if your geometry is made this way)
        glCullFace(GL_FRONT); // Cull front-facing triangles -> draw only back-facing triangles
    

    渲染场景时正常地渲染(剔除背面)

        glCullFace(GL_BACK); // Cull back-facing triangles -> draw only front-facing triangles
    

    代码中也用了这个方法,和“加入偏差”联合使用。

    阴影悬空(Peter Panning)

    现在没有阴影瑕疵了,但地面的光照效果还是不对,看上去墙面好像悬在半空(因此术语称为“阴影悬空”)。实际上,加上偏差会加剧阴影悬空。

    PeterPanning

    这个问题很好修正:避免使用薄的几何形体就行了。这样做有两个好处:

    • 首先,(把物体增厚)解决了阴影悬空问题:物体比偏差值要大得多,于是一切麻烦烟消云散了
    • 其次,可在渲染光照贴图时启用背面剔除,因为现在,墙壁上有一个面面对光源,就可以遮挡住墙壁的另一面,而这另一面恰好作为背面被剔除了,无需渲染。

    缺点就是要渲染的三角形增多了(每帧多了一倍的三角形!)

    NoPeterPanning-1024x793

    走样

    即使是使用了这些技巧,你还是会发现阴影的边缘上有一些走样。换句话说,就是一个像素点是白的,邻近的一个像素点是黑的,中间缺少平滑过渡。

    Aliasing

    PCF(percentage closer filtering,百分比渐近滤波)

    一个最简单的改善方法是把阴影贴图的sampler类型改为sampler2DShadow。这么做的结果是,每当对阴影贴图进行一次采样时,硬件就会对相邻的纹素进行采样,并对它们全部进行比较,对比较的结果做双线性滤波后返回一个[0,1]之间的float值。

    例如,0.5即表示有两个采样点在阴影中,两个采样点在光明中。

    注意,它和对滤波后深度图做单次采样有区别!一次“比较”,返回的是true或false;PCF返回的是4个“true或false”值的插值结果

    PCF_1tap

    可以看到,阴影边界平滑了,但阴影贴图的纹素依然可见。

    泊松采样(Poisson Sampling)

    一个简易的解决办法是对阴影贴图做N次采样(而不是只做一次)。并且要和PCF一起使用,这样即使采样次数不多,也可以得到较好的效果。下面是四次采样的代码:

    for (int i=0;i<4;i++){
      if ( texture2D( shadowMap, ShadowCoord.xy + poissonDisk[i]/700.0 ).z  <  ShadowCoord.z-bias ){
        visibility-=0.2;
      }
    }
    

    poissonDisk是一个常量数组,其定义看起来像这样:

    vec2 poissonDisk[4] = vec2[](
      vec2( -0.94201624, -0.39906216 ),
      vec2( 0.94558609, -0.76890725 ),
      vec2( -0.094184101, -0.92938870 ),
      vec2( 0.34495938, 0.29387760 )
    );
    

    这样,根据阴影贴图采样点个数的多少,生成的fragment会随之变明或变暗。

    SoftShadows-1024x793

    常量700.0确定了采样点的“分散”程度。散得太密,还是会发生走样;散得太开,会出现条带(截图中未使用PCF,以便让条带现象更明显;其中做了16次采样)

    SoftShadows_Close-1024x793

    SoftShadows_Wide-1024x793

    分层泊松采样(Stratified Poisson Sampling)

    通过为每个像素分配不同采样点个数,我们可以消除这一问题。主要有两种方法:分层泊松法(Stratified Poisson)和旋转泊松法(Rotated Poisson)。分层泊松法选择不同的采样点数;旋转泊松法采样点数保持一致,但会做随机的旋转以使采样点的分布发生变化。本课仅对分层泊松法作介绍。

    与之前版本唯一不同的是,这里用了一个随机数来索引poissonDisk:

        for (int i=0;i<4;i++) {
        int index = // A random number between 0 and 15, different for each pixel (and each i !)
        visibility -= 0.2*(1.0-texture( shadowMap, vec3(ShadowCoord.xy + poissonDisk[index]/700.0,  (ShadowCoord.z-bias)/ShadowCoord.w) ));
        }
    

    可用如下代码(返回一个[0,1]间的随机数)产生随机数

        float dot_product = dot(seed4, vec4(12.9898,78.233,45.164,94.673));
        return fract(sin(dot_product) * 43758.5453);
    

    本例中,seed4是参数i和seed的组成的vec4向量(这样才会是在4个位置做采样)。参数seed的值可以选用gl_FragCoord(像素的屏幕坐标),或者Position_worldspace:

             //  - A random sample, based on the pixel's screen location.
            //    No banding, but the shadow moves with the camera, which looks weird.
            int index = int(16.0*random(gl_FragCoord.xyy, i))%16;
            //  - A random sample, based on the pixel's position in world space.
            //    The position is rounded to the millimeter to avoid too much aliasing
            //int index = int(16.0*random(floor(Position_worldspace.xyz*1000.0), i))%16;
    

    这样做之后,上图中的那种条带就消失了,不过噪点却显现出来了。不过,一些“漂亮的”噪点可比上面那些条带“好看”多了。

    PCF_stratified_4tap

    上述三个例子的实现请参见tutorial16/ShadowMapping.fragmentshader。

    深入研究

    即使把这些技巧都用上,仍有很多方法可以提升阴影质量。下面是最常见的一些方法:

    早优化(Early bailing)

    不要把采样次数设为16,太大了,四次采样足矣。若这四个点都在光明或都在阴影中,那就算做16次采样效果也一样:这就叫过早优化。若这些采样点明暗各异,那你很可能位于阴影边界上,这时候进行16次采样才是合情理的。

    聚光灯(Spot lights)

    处理聚光灯这种光源时,不需要多大的改动。最主要的是:把正交投影矩阵换成透视投影矩阵:

    glm::vec3 lightPos(5, 20, 20);
    glm::mat4 depthProjectionMatrix = glm::perspective(45.0f, 1.0f, 2.0f, 50.0f);
    glm::mat4 depthViewMatrix = glm::lookAt(lightPos, lightPos-lightInvDir, glm::vec3(0,1,0));
    

    大部分都一样,只不过用的不是正交视域四棱锥,而是透视视域四棱锥。考虑到透视除法,采用了texture2Dproj。(见“第四课——矩阵”的脚注)

    第二步,在shader中,把透视考虑在内。(见“第四课——矩阵”的脚注。简而言之,透视投影矩阵根本就没做什么透视。这一步是由硬件完成的,只是把投影的坐标除以了w。这里在着色器中模拟这一步操作,因此得自己做透视除法。顺便说一句,正交矩阵产生的齐次向量w始终为1,这就是为什么正交矩阵没有任何透视效果。)

    用GLSL完成此操作主要有两种方法。第二种方法利用了内置的textureProj函数,但两种方法得出的效果是一样的。

    if ( texture( shadowMap, (ShadowCoord.xy/ShadowCoord.w) ).z  <  (ShadowCoord.z-bias)/ShadowCoord.w )
    if ( textureProj( shadowMap, ShadowCoord.xyw ).z  <  (ShadowCoord.z-bias)/ShadowCoord.w )
    

    点光源(Point lights)

    大部分是一样的,不过要做深度立方体贴图(cubemap)。立方体贴图包含一组6个纹理,每个纹理位于立方体的一面,无法用标准的UV坐标访问,只能用一个代表方向的三维向量来访问。

    空间各个方向的深度都保存着,保证点光源各方向都能投射影子。T

    多个光源组合

    该算法可以处理多个光源,但别忘了,每个光源都要做一次渲染,以生成其阴影贴图。这些计算极大地消耗了显存,也许很快你的显卡带宽就吃紧了。

    自动光源四棱锥(Automatic light frustum)

    本课中,囊括整个场景的光源四棱锥是手动算出来的。虽然在本课的限定条件下,这么做还行得通,但应该避免这样的做法。如果你的地图大小是1Km x 1Km,你的阴影贴图大小为1024x1024,则每个纹素代表的面积为1平方米。这么做太蹩脚了。光源的投影矩阵应尽量紧包整个场景。

    对于聚光灯来说,只需调整一下范围就行了。

    对于太阳这样的方向光源,情况就复杂一些:光源确实照亮了整个场景。以下是计算方向光源视域四棱锥的一种方法:

    潜在阴影接收者(Potential Shadow Receiver,PSR)。PSR是这样一种物体——它们同时在【光源视域四棱锥,观察视域四棱锥,以及场景包围盒】这三者之内。顾名思义,PSR都有可能位于阴影中:相机和光源都能“看”到它。

    潜在阴影投射者(Potential Shadow Caster,PSC)= PSR + 所有位于PSR和光源之间的物体(一个物体可能不可见但仍然会投射出一条可见的阴影)。

    因此,要计算光源的投影矩阵,可以用所有可见的物体,“减去”那些离得太远的物体,再计算其包围盒;然后“加上”位于包围盒与广元之间的物体,再次计算新的包围盒(不过这次是沿着光源的方向)。

    这些集合的精确计算涉及凸包体的求交计算,但这个方法(计算包围盒)实现起来简单多了。

    此法在物体离开视域四棱锥时,计算量会陡增,原因在于阴影贴图的分辨率陡然增加了。你可以通过多次平滑插值来弥补。CSM(Cascaded Shadow Map,层叠阴影贴图法)无此问题,但实现起来较难。

    指数阴影贴图(Exponential shadow map)

    指数阴影贴图法试图借助“位于阴影中的、但离光源较近的片断实际上处于‘某个中间位置’”这一假设来减少走样。这个方法涉及到偏差,不过测试已不再是二元的:片断离明亮曲面的距离越远,则其越显得黑暗。

    显然,这纯粹是一种障眼法,两物体重叠时,瑕疵就会显露出来。

    LiSPSM(Light-space perspective Shadow Map,光源空间透视阴影贴图)

    LiSPSM调整了光源投影矩阵,从而在离相机很近时获取更高的精度。这一点在“duelling frustra”现象发生时显得尤为重要。所谓“duelling frustra”是指:点光源与你(相机)距离远,『视线』方向又恰好与你的视线方向相反。离光源近的地方(即离你远的地方),阴影贴图精度高;离光源远的地方(即离你近的地方,你最需要精确阴影贴图的地方),阴影贴图的精度又不够了。

    不过LiSPSM实现起来很难。详细的实现方法请看参考文献。

    CSM(Cascaded shadow map,层叠阴影贴图)
    CSM和LiSPSM解决的问题一模一样,但方式不同。CSM仅对观察视域四棱锥的各部分使用了2~4个标准阴影贴图。第一个阴影贴图处理近处的物体,所以在近处这块小区域内,你可以获得很高的精度。随后几个阴影贴图处理远一些的物体。最后一个阴影贴图处理场景中的很大一部分,但由于透视效应,视觉感官上没有近处区域那么明显。

    撰写本文时,CSM是复杂度/质量比最好的方法。很多案例都选用了这一解决方案。

    总结

    正如您所看到的,阴影贴图技术是个很复杂的课题。每年都有新的方法和改进方案发表。但目前为止尚无完美的解决方案。

    幸运的是,大部分方法都可以混合使用:在LiSPSM中使用CSM,再加PCF平滑等等是完全可行的。尽情地实验吧。

    总结一句,我建议您坚持尽可能使用预计算的光照贴图,只为动态物体使用阴影贴图。并且要确保两者的视觉效果协调一致,任何一者效果太好/太坏都不合适。

    ?>