[OpenGL ES 07-2]Per-Vertex Light及深度缓存
[OpenGL ES 07-2]Per-Vertex Light及深度缓存
罗朝辉 (http://www.cnblogs.com/kesalin/)
本文遵循“署名-非商业用途-保持一致”创作公用协议
这是《OpenGL ES 教程》的第八篇,前七篇请参考如下链接:
[OpenGL ES 01]iOS上OpenGL ES之初体验
[OpenGL ES 02]OpenGL ES渲染管线与着色器
[OpenGL ES 03]3D变换:模型,视图,投影与Viewport
[OpenGL ES 04]3D变换实践篇:平移,旋转,缩放
[OpenGL ES 05]相对空间变换及颜色
[OpenGL ES 06]使用VBO:顶点缓存
[OpenGL ES 07-1]光照原理
前言
在前文《[OpenGL ES 07-1]光照原理》中已经介绍 Opengl 中的光照原理,接下来将演示如何将这些原理用 OpenGL ES 2.0 来实现。今天的这篇文章将介绍 Per-Vertex Light 以及深度缓存,下一篇文章将介绍 Per-Pixel Light 以及卡通效果。还记得在第六篇文章的末尾留了一个小作业,用顶点缓存描绘一个立方体么 Cube?在这篇文章就会用到它。Per-Vertex Light 示例源码在这里,运行效果如下:
在开始之前,先来回顾一下 Per-Vertex Light 是怎么回事。Per-Vertex Light 也称为 Gauroud 着色,它是在顶点着色阶段对每一个顶点进行颜色计算,然后在光栅化阶段利用这些顶点颜色进行线性插值形成片元的颜色。
一,准备工作
1,新建工程
和前面的文章一样,新建名为 Tutorial07 的 Single View Application,导入 OpenGLES.framework 和 QuartzCore.framework。然后将 Tutorial06 中的 Utils, Shader,Surface三个目录以及 OpenGLView.h/m 两个文件拷贝到 Tutorial07 中,并在 XCode 中将它们加入进来。
2,添加 Cube 类型的 VBO
将第六篇的小作业:为 Cube 的 VBO 的那部分代码 - (DrawableVBO *)createVBOsForCube 加入到 OpenGLView.m 中,并修改 - (void)setupVBOs 的实现,在 _vboArray 加入 Cube 这个类型。这部分代码与本文主题不相干,所以就不在这里累述了,详情请参看源代码。
3,添加控制控件
参照效果图,在 Storyboard 中添加相关控件:
4,添加响应代码
和前面的示例一样,在 ViewController 中加入相关响应代码,并使用拖拽技巧与 Storyboard 中的对应控件关联起来。下面只列出一部分代码,完整代码请参考源代码。
@property (nonatomic, strong) IBOutlet OpenGLView * openGLView; @property (nonatomic, strong) IBOutlet UISlider * lightXSlider; // ... - (IBAction)lightXSliderValueChanged:(id)sender; // ... - (IBAction)segmentSelectionChanged:(id)sender;
二,Per-Vertex Light 实现
1,修改顶点着色器
在本文中,光照计算的实际工作都是在顶点着色器中进行的,因此首先修改顶点着色器 VertexShader.glsl 如下:
uniform mat4 projection; uniform mat4 modelView; attribute vec4 vPosition; uniform mat3 normalMatrix; uniform vec3 vLightPosition; uniform vec4 vAmbientMaterial; uniform vec4 vSpecularMaterial; uniform float shininess; attribute vec3 vNormal; attribute vec4 vDiffuseMaterial; varying vec4 vDestinationColor; void main(void) { gl_Position = projection * modelView * vPosition; vec3 N = normalMatrix * vNormal; vec3 L = normalize(vLightPosition); vec3 E = vec3(0, 0, 1); vec3 H = normalize(L + E); float df = max(0.0, dot(N, L)); float sf = max(0.0, dot(N, H)); sf = pow(sf, shininess); vDestinationColor = vAmbientMaterial + df * vDiffuseMaterial + sf * vSpecularMaterial; //vDestinationColor = vec4(1.0, 0.0, 0.0, 1.0); }
在这里,添加了光源位置:vLightPosition,以及三种类型的材质属性:环境材质 vAmbientMaterial,漫反射材质 vDiffuseMaterial 和镜面反射材质 vSpecularMaterial。这些都在前文《光照原理》中介绍过了,在这里就不再重复了。光照计算的最终颜色等于三种类型的光照效果的累加:
vDestinationColor = vAmbientMaterial + df * vDiffuseMaterial + sf * vSpecularMaterial;
df 漫反射因子:它是光线与顶点法线向量的点积,几何意义就是光线 L 与法线 N 之间夹角的 cos 值;
sf 镜面反射因子:它是视线 E 与光线 L 形成的夹角的平分线 H 与顶点法线向量的点积(几何意义就是平分线 H 与顶点法线 N 之间夹角的 cos 值),再 shininess 次方;
平分线向量 H:它是通过将视线向量 E 与 光线向量 L 相加,并规范化计算而来;
shininess 光泽强度:它由 OpenGL 程序传入。当然啦,最终是在 UI 通过 shininess 这个滑块控制的,该值越小,光泽强度越大。
前面提到过,OpenGL 中的很多计算都要将向量规划化,在这里就体现出来了,比如法线,平分线,位置向量等。在上面的代码中,还从 OpenGL 程序中传入了法线变换矩阵 normalMatrix,这个值得一说。
2,法线变换矩阵
为什么需要法线变换矩阵呢?因为法线向量与顶点向量一样,是在物体的模型空间中,而光照计算通常是在视图空间中进行的,因此我们需要将模型空间中的法线向量变换到视图空间,这是原因之一。或许你或说,这个变换直接用和用于顶点变换的模型视图变换矩阵 modelView 就可以了呀。诚然,当模型视图变换是刚体变换时,法线变换矩阵与模型视图变换矩阵完全一样;但如果模型视图变换不是刚体变换时,两者就不相同了。所谓刚体变换就是说物体在 x, y, z 三个方向进行了等比的缩放操作。只有刚体变换这种情况下,顶点的法线向量方向才不会改变,而在非刚体变换时,法线向量的方向会有变化。试想一下,圆球上顶点的法线向量是从球心指向顶点,当将圆球在 y 方向进行缩放而 x,z 方向保持不变,经过这样的非刚体变换之后,这个压扁的“橄榄球”上的顶点的法线向量肯定有一部分不再是从球心指向顶点了。非刚体变换的法线变换矩阵计算公式如下:
非刚体变换的法线变换矩阵 = 模型视图变换矩阵的逆矩阵的倒置矩阵
这个计算过程分为两步:首先对模型视图变换矩阵求逆,然后再倒置(即交换行列元素)。进行刚体变换的模型视图变换矩阵的逆矩阵的倒置矩阵就等于模型视图变换矩阵自身,所以在进行刚体变换时(如本例),只需要将,法线变换矩阵的值设置为模型视图变换矩阵的值即可。
三,设置光照
1,访问顶点着色器变量
和教程 6 一样,需要在 OpenGLView 中添加访问顶点着色器中变量的相关槽位变量,以及设置光照参数的变量。在OpenGLView.h 中,添加如下变量:
GLuint _positionSlot; GLuint _modelViewSlot; GLuint _projectionSlot; GLuint _normalMatrixSlot; GLuint _lightPositionSlot; GLint _normalSlot; GLint _ambientSlot; GLint _diffuseSlot; GLint _specularSlot; GLint _shininessSlot; KSMatrix4 _modelViewMatrix; KSMatrix4 _projectionMatrix; KSVec3 _lightPosition; KSColor _ambient; KSColor _diffuse; KSColor _specular; GLfloat _shininess;
由于我们需要在 UI 上控制一些光照参数,因此需要将上面中的一些变量声明为属性,以方便 UI 更新它们,这些光照参数在更新之后需要重绘才能立即看到效果:
// OpenGLView.h // @property (nonatomic, assign) KSVec3 lightPosition; @property (nonatomic, assign) KSColor ambient; @property (nonatomic, assign) KSColor diffuse; @property (nonatomic, assign) KSColor specular; @property (nonatomic, assign) GLfloat shininess; // OpenGLView.m // @synthesize lightPosition = _lightPosition; @synthesize ambient = _ambient; @synthesize diffuse = _diffuse; @synthesize specular = _specular; @synthesize shininess = _shininess; #pragma mark Properties -(void)setAmbient:(KSColor)ambient { _ambient = ambient; [self render]; } -(void)setSpecular:(KSColor)specular { _specular = specular; [self render]; } - (void)setLightPosition:(KSVec3)lightPosition { _lightPosition = lightPosition; [self render]; } -(void)setDiffuse:(KSColor)diffuse { _diffuse = diffuse; [self render]; } -(void)setShininess:(GLfloat)shininess { _shininess = shininess; [self render]; }
2,访问槽位
在 OpenGLView.m 中添加 getSlotsFromProgram 方法,并在 setupProgram 方法的最后调用它。
- (void)getSlotsFromProgram { // Get the attribute and uniform slot from program // _projectionSlot = glGetUniformLocation(_programHandle, "projection"); _modelViewSlot = glGetUniformLocation(_programHandle, "modelView"); _normalMatrixSlot = glGetUniformLocation(_programHandle, "normalMatrix"); _lightPositionSlot = glGetUniformLocation(_programHandle, "vLightPosition"); _ambientSlot = glGetUniformLocation(_programHandle, "vAmbientMaterial"); _specularSlot = glGetUniformLocation(_programHandle, "vSpecularMaterial"); _shininessSlot = glGetUniformLocation(_programHandle, "shininess"); _positionSlot = glGetAttribLocation(_programHandle, "vPosition"); _normalSlot = glGetAttribLocation(_programHandle, "vNormal"); _diffuseSlot = glGetAttribLocation(_programHandle, "vDiffuseMaterial"); }
3,初始化和更新光照参数
在 OpenGLView.m 中添加 setupLights 和 updateLights 的方法,对光照参数进行初始化和更新光照参数到顶点着色器中:
- (void)setupLights { // Initialize various state. // glEnableVertexAttribArray(_positionSlot); glEnableVertexAttribArray(_normalSlot); // Set up some default material parameters. // _lightPosition.x = _lightPosition.y = _lightPosition.z = 1.0; _ambient.r = _ambient.g = _ambient.b = 0.04; _specular.r = _specular.g = _specular.b = 0.5; _diffuse.r = 0.0; _diffuse.g = 0.5; _diffuse.b = 1.0; _shininess = 10; } - (void)updateLights { glUniform3f(_lightPositionSlot, _lightPosition.x, _lightPosition.y, _lightPosition.z); glUniform4f(_ambientSlot, _ambient.r, _ambient.g, _ambient.b, _ambient.a); glUniform4f(_specularSlot, _specular.r, _specular.g, _specular.b, _specular.a); glVertexAttrib4f(_diffuseSlot, _diffuse.r, _diffuse.g, _diffuse.b, _diffuse.a); glUniform1f(_shininessSlot, _shininess); }
setupLights 方法在 - (id)initWithCoder:(NSCoder *)aDecoder 中 setProjection 之后被调用,而 updateLights 在 updateSurface 的最后被调用,该方法在每次渲染时都会被调用(其实只需要在光照参数有变换时调用即可,在这里偷懒没有做这个优化了)。
前面说过,在顶点着色器中需要利用法线变换矩阵变换法线到视图空间,因此,也需要在程序中设置法线变换矩阵。在这里进行的是刚体变换,所以只需要将模型变换矩阵的值赋值给法线变换矩阵即可。这个赋值是在 updateSurface 方法中进行的,下面是 updateSurface 的完整代码:
- (void)updateSurface { ksMatrixLoadIdentity(&_modelViewMatrix); ksTranslate(&_modelViewMatrix, 0.0, 0.0, -8); ksMatrixMultiply(&_modelViewMatrix, &_rotationMatrix, &_modelViewMatrix); // Load the model-view matrix glUniformMatrix4fv(_modelViewSlot, 1, GL_FALSE, (GLfloat*)&_modelViewMatrix.m[0][0]); // Load the normal matrix. // It's orthogonal, so its Inverse-Transpose is itself! // KSMatrix3 normalMatrix3; ksMatrix4ToMatrix3(&normalMatrix3, &_modelViewMatrix); glUniformMatrix3fv(_normalMatrixSlot, 1, GL_FALSE, (GLfloat*)&normalMatrix3.m[0][0]); [self updateLights]; }
4,至此,光照设置完成。如果没出什么差错的话,编译应该是能够运行了。效果如下:
在上图中,我们确实可以看到光照的效果了。但是,这样的效果实在太二了!为什么会有不该出现的阴影呢?哪些阴影就是啥?且看下段分解!
四,Depth Buffer-深度缓存
1,上面的阴影问题分析
从上面的图中我们可以看到圆球的表面有一部分似乎被一些阴影给遮盖了,没错,确实是这样的。那这些阴影又是从何而来呢?它们也是球体的一部分,只不过是属于另外一个半球的-后半球面上的。在现实生活中,当我们看到一个球时,只能看到一个球的前半球,后半球是被挡住了,看不到。但在 OpenGL 中渲染一个球时,前半球和后半球都会被渲染出来,这样就会出现前后两个半球上的x,y相同只是z值不同两个点会被描绘到屏幕上的同一个像素上,先渲染的点会被后渲染的点给覆盖掉。因此,如果前半球面上的点(Z值较小的那一个)先被渲染,随后后半球面上的点(Z值较大的那一个)被渲染,会覆盖前半球面上的点,因而出现了上面那样的情况。解决这个问题的办法就是在渲染之前,对Z值进行比较,不渲染 Z 值较大的点。在 OpenGL 中,这是通过 Depth Test 实现的,之所以叫做深度测试就是因为比较的是 Z 值 - 深度-从屏幕往里。还记得教程02中渲染管线流程图么?深度测试是在模版测试之后,blending 之前。
2,Depth Buffer 简介
要进行Z值的比较(深度测试),那么 OpenGL 需要有一个地方来保存Z值,这个地方就是 Depth Buffer。Depth Buffer 与 Stencil Buffer,Color Buffer 并称 OpenGL 三大缓存。Depth Buffer 和 Stencil Buffer 也是 render buffer,但与 Color Buffer 不同(RGBA四元组),它们均只有一个组成元素,Depth Buffer 只需要保存Z值,而 Stencil Buffer(模版缓存)也只需要保存一个用于模版测试的值(后面会有文章介绍)。
3,使用 Depth Buffer
既然 Depth Buffer 也是 render buffer,那么其创建与删除与之前在教程01中介绍的 color buffer 别无二样:
// OpenGLView.h // GLuint _depthRenderBuffer; // OpenGLView.m // // Create a depth buffer that has the same size as the color buffer. int width, height; glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &width); glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &height); glGenRenderbuffers(1, &_depthRenderBuffer); glBindRenderbuffer(GL_RENDERBUFFER, _depthRenderBuffer); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT16, width, height); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, _depthRenderBuffer);
删除也是使用 glDeleteRenderbuffers 方法。
默认情况下,OpenGL 是不会开启深度测试的,因此需要明确调用 glEnable(GL_DEPTH_TEST) 来开启。在 setupProjection 方法的最后添加这一句即可。
4,编译运行,啊哈,一切OK!
在本示例中,有很多滑块用来控制各种光照参数(环境材质,漫反射材质,镜面材质以及光泽强度),并有两个模型可供切换。不妨多多滑动,体验下不同的参数会有什么效果,从而加深对 OpenGL 光照的理解。
五,结语
有了前文《光照原理》 的理论基础,今天来实践 Per-Vextex 光照,非常容易吧。接下来将介绍 Per-Pixel 光照以及卡通效果,性急的童鞋可以先浏览源代码,看看能不能看个明白。BTW,教程代码已经非常超前了-写到教程13了,写文章的速度大大落后了。写文章花费的时间和精力实在是不少的,深度体会在中国写一本书的辛苦,而往往回报远不及付出,嗯,要多多支持正版。