學習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來開啟和關閉法線貼圖。

下面是最終運行效果。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 本文主要解決一個問題: 如何使用法線貼圖給物體添加更多的細節? 引言 學了這么多技巧,也能顯示非常酷炫的畫面,是不...
    閃電的藍熊貓閱讀 10,155評論 14 28
  • 法線貼圖是一副紋理圖,只是紋理圖上的點保存的不是RGB數據,我們是將壓縮過的x,y,z軸坐標保存到red,gree...
    壹米玖坤閱讀 4,066評論 0 3
  • <轉>我也忘了轉自哪里,抱歉,感謝原作者 什么是Shader Shader(著色器)是一段能夠針對3D對象進行操作...
    星易乾川閱讀 5,655評論 1 16
  • 我們都知道,一個三維場景的畫面的好壞,百分之四十取決于模型,百分之六十取決于貼圖,可見貼圖在畫面中所占的重要性。在...
    自由的天空閱讀 12,423評論 0 12
  • 哥哥,我想要一條冰冷的河, 里面擠滿冷血的蛇。 河在黑夜里流淌, 蛇在河里熬著饑餓。 哥哥,我只有我,...
    九年年閱讀 387評論 1 5