学习OpenGL ES之法线贴图

本系列所有文章目录

获取示例代码


本文将给大家介绍法线贴图的相关知识,在游戏中由于GPU资源有限,尤其是在移动设备中,所以无法使用大量的三角形来表示3D模型的细节。这时候法线贴图就成为了折中的渲染方案,既能够带来不错的细节表现效果,还可以减少资源的消耗。

未使用法线贴图的Cube
使用了法线贴图的Cube

通过上面的图可以看出,法线贴图给Cube表面增加了很多光照上的细节。

什么是法线贴图

到目前为止我们接触到的贴图只有漫反射贴图,我们通过UV从漫反射贴图中提取颜色,然后使用光照模型处理。法线贴图同样也是一张图,可以使用UV从中提取颜色,只不过我们需要把颜色转换成法线向量。下面是本文的例子使用的法线贴图。


本文的法线贴图由CrazyBump生成

是不是感觉很奇怪?下面我来揭露这张诡异贴图下隐藏的秘密。

颜色如何转换成法线向量

RGB颜色数据的范围是(0, 0, 0 )(1,1,1),所以要把它转换成法线向量,需要所有元素乘以2再减去1,这样取值范围就变成了(-1, -1, -1 )(1,1,1),这是规范化后的向量该有的取值范围。那么这个时候是不是就能直接使用它计算光照强度了呢?

法线空间

通过颜色转换过来的法线向量并不是世界空间的向量,不能直接用来计算光照。那么什么是世界空间?我们通过一个1维的例子来解释一下。下面是一个数轴,中间是原点,点A的坐标是3。我们可以称坐标3是A在世界空间的坐标。世界空间就是没有经过任何变换的数轴形成的坐标系统。


我们增加点B,它在世界空间的坐标是7,如果此时我们将A设置为原点,那么B的坐标就变成了4。我们可以说B在点A物体空间的坐标是4。我们通过B在点A物体空间中的坐标和A在世界空间的坐标就可以计算出点B在世界空间的坐标7。

我们回到3D世界,每个顶点都有一个对应的法线,我们可以使用这个法线向量和它的两个切线向量形成一个法线空间。通过颜色转换过来的法线向量正是相对于这个法线空间的值。我们使用相对于法线空间的向量值和法线空间相对于世界空间的变换就可以计算出最终的法线向量了。我们可以把通过颜色转换过来的法线向量看做上面点B在点A物体空间的坐标,法线空间则看做点A在世界空间的变换,最后计算出点B在世界空间的坐标。


计算法线空间变换

想要计算法线空间的变换需要一个法线,两个互相垂直的切线。切线和法线是垂直的,所以一个法线可以有很多个切线,为了产生较好的效果,我们选择沿着UV方向的切线。如下图所示,第一个切线Tangent沿着U,第二个切线Bitangent沿着V。(UX, VX)是各个点的UV坐标,Line10P0P1的向量。Line20P0P2的向量。他们满足下面的公式。

Line10 = (U1 - U0) * Tangent + (V1 - V0) * Bitangent
Line20 = (U2 - U0) * Tangent + (V2 - V0) * Bitangent

我们将U1 - U0记做ΔU1,V1 - V0记做ΔV1U2 - U0记做ΔU2,V2 - V0记做ΔV2。最终可以推导出下面的公式。

求解出TangentBitangent后就可以将它们和法线组成法线空间变换TBN了,T是Tangent,B是Bitangent,N是Normal

了解完法线贴图的基础知识后,现在我们来开始实践部分。为了实现法线贴图,Shader需要做哪些事情呢?

因为法线贴图一般只对原有法线进行比较小的扰动,所以大部分的值在法线空间中z轴上分量比较多,z轴会被写到rgb的blue分量中,所以法线贴图会呈现出蓝色的主色调。

在Shader中如何使用法线贴图

首先需要在Vertex Shader中增加两个切线的attribute,并把他们传递给Fragment Shader。

attribute vec3 tangent;
attribute vec3 bitangent;
...
varying vec3 fragTangent;
varying vec3 fragBitangent;
...
void main(void) {
     ...
    fragTangent = tangent;
    fragBitangent = bitangent;
    gl_Position = mvp * position;
}

在Fragment Shader中需要做一下几件事。

  • 添加接受法线贴图的uniform,uniform sampler2D normalMap;
  • 将法线和切线都使用normalMatrix进行变换,从而变换到世界空间。
vec3 transformedNormal = normalize((normalMatrix * vec4(fragNormal, 1.0)).xyz);
vec3 transformedTangent = normalize((normalMatrix * vec4(fragTangent, 1.0)).xyz);
vec3 transformedBitangent = normalize((normalMatrix * vec4(fragBitangent, 1.0)).xyz);
  • 使用法线和切线组成TBN矩阵。
    mat3 TBN = mat3(
                              transformedTangent,
                              transformedBitangent,
                              transformedNormal
                              );
  • 取出法线贴图的值并使用TBN变换。
vec3 normalFromMap = (texture2D(normalMap, fragUV).rgb * 2.0 - 1.0);
transformedNormal = TBN * normalFromMap;

接下来就是和以前一样的流程了,使用transformedNormal参与你的光照模型的计算。

为顶点计算切线

我只在WavefrontObj类中实现了切线的计算,所有的生成代码如下。

- (void)decompressToVertexArray {
    NSInteger vertexCount = self.positionIndexData.length / sizeof(GLuint);
    NSInteger triangleCount = vertexCount / 3;
    for (int triangleIndex = 0; triangleIndex < triangleCount; ++triangleIndex) {
        GLKVector3 positions[3];
        GLKVector2 uvs[3];
        GLKVector3 normals[3];
        for (int vertexIndex = triangleIndex * 3; vertexIndex < triangleIndex * 3 + 3; ++vertexIndex) {
            int positionIndex = 0;
            [self.positionIndexData getBytes:&positionIndex range:NSMakeRange(vertexIndex * sizeof(GLuint), sizeof(GLuint))];
            [self.positionData getBytes:&positions[vertexIndex % 3] range:NSMakeRange(positionIndex * 3 * sizeof(GLfloat), 3 * sizeof(GLfloat))];
            
            int normalIndex = 0;
            [self.normalIndexData getBytes:&normalIndex range:NSMakeRange(vertexIndex * sizeof(GLuint), sizeof(GLuint))];
            [self.normalData getBytes:&normals[vertexIndex % 3] range:NSMakeRange(normalIndex * 3 * sizeof(GLfloat), 3 * sizeof(GLfloat))];
            
            int uvIndex = 0;
            [self.uvIndexData getBytes:&uvIndex range:NSMakeRange(vertexIndex * sizeof(GLuint), sizeof(GLuint))];
            [self.uvData getBytes:&uvs[vertexIndex % 3] range:NSMakeRange(uvIndex * 2 * sizeof(GLfloat), 2 * sizeof(GLfloat))];
        }
        GLKVector3 deltaPos1 = GLKVector3Subtract(positions[1], positions[0]);
        GLKVector3 deltaPos2 = GLKVector3Subtract(positions[2], positions[0]);
        GLKVector2 deltaUV1 = GLKVector2Subtract(uvs[1], uvs[0]);
        GLKVector2 deltaUV2 = GLKVector2Subtract(uvs[2], uvs[0]);
        float r = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x);
        
        GLKVector3 tangent = GLKVector3MultiplyScalar(GLKVector3Subtract(GLKVector3MultiplyScalar(deltaPos1, deltaUV2.y), GLKVector3MultiplyScalar(deltaPos2, deltaUV1.y)), r);
        GLKVector3 bitangent = GLKVector3MultiplyScalar(GLKVector3Subtract(GLKVector3MultiplyScalar(deltaPos2, deltaUV1.x), GLKVector3MultiplyScalar(deltaPos1, deltaUV2.x)), r);
        
        for (int i = 0; i< 3; ++i) {
            [self.vertexData appendBytes:&positions[i] length:sizeof(GLKVector3)];
            [self.vertexData appendBytes:&normals[i] length:sizeof(GLKVector3)];
            [self.vertexData appendBytes:&uvs[i] length:sizeof(GLKVector2)];
            [self.vertexData appendBytes:&tangent length:sizeof(GLKVector3)];
            [self.vertexData appendBytes:&bitangent length:sizeof(GLKVector3)];
        }
    }
}

以三角形为基本单位,计算它的两根切线。下面的deltaPos1对应Line10,deltaPos2对应Line20,deltaUV1和deltaUV2则是UV上的差值,读者可以对应公式理解这里的代码。


GLKVector3 deltaPos1 = GLKVector3Subtract(positions[1], positions[0]);
GLKVector3 deltaPos2 = GLKVector3Subtract(positions[2], positions[0]);
GLKVector2 deltaUV1 = GLKVector2Subtract(uvs[1], uvs[0]);
GLKVector2 deltaUV2 = GLKVector2Subtract(uvs[2], uvs[0]);
float r = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x);
        
GLKVector3 tangent = GLKVector3MultiplyScalar(GLKVector3Subtract(GLKVector3MultiplyScalar(deltaPos1, deltaUV2.y), GLKVector3MultiplyScalar(deltaPos2, deltaUV1.y)), r);
GLKVector3 bitangent = GLKVector3MultiplyScalar(GLKVector3Subtract(GLKVector3MultiplyScalar(deltaPos2, deltaUV1.x), GLKVector3MultiplyScalar(deltaPos1, deltaUV2.x)), r);
    

下面是2x2和2x3矩阵的乘法规律,读者可以参考下。


切线计算完后,我们将两根切线放到顶点数据中。

for (int i = 0; i< 3; ++i) {
    [self.vertexData appendBytes:&positions[i] length:sizeof(GLKVector3)];
    [self.vertexData appendBytes:&normals[i] length:sizeof(GLKVector3)];
    [self.vertexData appendBytes:&uvs[i] length:sizeof(GLKVector2)];
    [self.vertexData appendBytes:&tangent length:sizeof(GLKVector3)];
    [self.vertexData appendBytes:&bitangent length:sizeof(GLKVector3)];
}

修改顶点数据结构

我们可以发现,现在的顶点数据改变了,新增了两根法线,所以绑定VAO的代码也需要改变,下面是WavefrontObj新的genVAO代码。

- (void)genVAO {
    glGenVertexArraysOES(1, &vao);
    glBindVertexArrayOES(vao);
    
    glBindBuffer(GL_ARRAY_BUFFER, vertexVBO);
    
    GLuint positionAttribLocation = glGetAttribLocation(self.context.program, "position");
    glEnableVertexAttribArray(positionAttribLocation);
    GLuint colorAttribLocation = glGetAttribLocation(self.context.program, "normal");
    glEnableVertexAttribArray(colorAttribLocation);
    GLuint uvAttribLocation = glGetAttribLocation(self.context.program, "uv");
    glEnableVertexAttribArray(uvAttribLocation);
    GLuint tangentAttribLocation = glGetAttribLocation(self.context.program, "tangent");
    glEnableVertexAttribArray(tangentAttribLocation);
    GLuint bitangentAttribLocation = glGetAttribLocation(self.context.program, "bitangent");
    glEnableVertexAttribArray(bitangentAttribLocation);
    
    glVertexAttribPointer(positionAttribLocation, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (char *)NULL);
    glVertexAttribPointer(colorAttribLocation, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (char *)NULL + 3 * sizeof(GLfloat));
    glVertexAttribPointer(uvAttribLocation, 2, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (char *)NULL + 6 * sizeof(GLfloat));
    glVertexAttribPointer(tangentAttribLocation, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (char *)NULL + 8 * sizeof(GLfloat));
    glVertexAttribPointer(bitangentAttribLocation, 3, GL_FLOAT, GL_FALSE, 14 * sizeof(GLfloat), (char *)NULL + 11 * sizeof(GLfloat));
    
    glBindVertexArrayOES(0);
}

每个顶点的数据长度变为了14个GLfloat的长度,新增了2个切线属性的绑定,对应着我们在Vertex Shader中新增的两个属性。

贴图绑定

我们在WavefrontObj的- (void)draw:(GLContext *)glContext中增加了法线贴图的绑定。

[glContext bindTexture:self.diffuseMap to:GL_TEXTURE0 uniformName:@"diffuseMap"];
[glContext bindTexture:self.normalMap to:GL_TEXTURE1 uniformName:@"normalMap"];

我们把漫反射贴图绑定到纹理0通道,法线贴图绑定到纹理1通道。

最后我们在ViewController中创建一个来自Obj文件的Cube模型,并给与它木箱的漫反射贴图和法线贴图。

- (void)createMonkeyFromObj {
    UIImage *normalImage = [UIImage imageNamed:@"normal.png"];
    GLKTextureInfo *normalMap = [GLKTextureLoader textureWithCGImage:normalImage.CGImage options:nil error:nil];
    UIImage *diffuseImage = [UIImage imageNamed:@"texture.jpg"];
    GLKTextureInfo *diffuseMap = [GLKTextureLoader textureWithCGImage:diffuseImage.CGImage options:nil error:nil];
    
    NSString *objFilePath = [[NSBundle mainBundle] pathForResource:@"cube" ofType:@"obj"];
    self.carModel = [WavefrontOBJ objWithGLContext:self.glContext objFile:objFilePath diffuseMap:diffuseMap normalMap:normalMap];
    self.carModel.modelMatrix = GLKMatrix4MakeRotation(- M_PI / 2.0, 0, 1, 0);
    [self.objects addObject:self.carModel];
}

为了方便查看纹理贴图的效果,我增加了uniform useNormalMap来开启和关闭法线贴图。

下面是最终运行效果。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 本文主要解决一个问题: 如何使用法线贴图给物体添加更多的细节? 引言 学了这么多技巧,也能显示非常酷炫的画面,是不...
    闪电的蓝熊猫阅读 9,889评论 14 27
  • 法线贴图是一副纹理图,只是纹理图上的点保存的不是RGB数据,我们是将压缩过的x,y,z轴坐标保存到red,gree...
    壹米玖坤阅读 3,808评论 0 3
  • <转>我也忘了转自哪里,抱歉,感谢原作者 什么是Shader Shader(着色器)是一段能够针对3D对象进行操作...
    星易乾川阅读 5,505评论 1 16
  • 我们都知道,一个三维场景的画面的好坏,百分之四十取决于模型,百分之六十取决于贴图,可见贴图在画面中所占的重要性。在...
    自由的天空阅读 12,256评论 0 12
  • 哥哥,我想要一条冰冷的河, 里面挤满冷血的蛇。 河在黑夜里流淌, 蛇在河里熬着饥饿。 哥哥,我只有我,...
    九年年阅读 356评论 1 5