OpenGL ES

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%2015%20Lightmaps%20opengl-toturial.org.md

简介

这堂课是视频课程,没有介绍新的OpenGL相关技术/语法。不过,大家会学习如何利用现有知识,生成高质量的阴影。

本课介绍了用Blender创建简单场景的方法;还介绍了如何烘培(bake)光照贴图(lightmap),以便在你的项目中使用。

lighmappedroom-1024x793

无需Blender预备知识,我会讲解包括快捷键的所有内容

关于光照贴图

光照图是永久、一次性地烘焙好的。也就是说光照图是完全静态的,你不能在运行时移动光源,连删除都不行。

但对于阳光这种光源来说,光照图还是大有用武之地的;在不会打碎灯泡的室内场景中,也是可以的。2009年发布的《镜之边缘》(Mirror Edge)室内、室外场景中大量采用了光照图。

更重要的是,光照图很容易配置,速度无可匹敌。

视频

这是个1024x768 高清视频。

Youku 标清含中文字幕
Vimeo 高清原版视频

附录

用OpenGL渲染时,你大概会注意到一些瑕疵(这里故意把瑕疵放大了):

positivebias-1024x793

这是由mipmap造成的。从远处观察时,mipmap对纹素做了混合。纹理背景中的黑色像素点和光照图中的像素点混合在了一起。为了避免这一点,可以采取如下措施:

  • 让Blender在UV图的limits上生成一个margin。这个margin参数位于bake面板。要想效果更好,可以把margin值设为20个纹素。
  • 获取纹理时,加上一个偏离(bias):

    color = texture2D( myTextureSampler, UV, -2.0 ).rgb;
    

    -2是偏离量。这个值是通过不断尝试得出的。上面的截图中bias值为+2,也就是说OpenGL将在原本的mipmap层次上再加两层(因此,纹素大小变为原来的1/16,瑕疵也随之变小了)。-

  • 后期处理中可将背景填充为黑色,这一点我后面还会再讲。

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%2014%20%20Render%20To%20Texture%20opengl-tutorial.org.md

“渲染到纹理”是一系列特效方法之一。基本思想是:像通常那样渲染一个场景——只是这次是渲染到可以重用的纹理中。

应用包括:游戏(in-game)相机、后期处理(post-processing)以及你能想象到一切.

渲染到纹理

我们有三个任务:创建要渲染的纹理对象;将纹理渲染到对象上;使用生成的纹理。

创建渲染目标(Render Target)

我们要渲染的对象叫做帧缓存。它像一个容器,用来存纹理和一个可选的深度缓冲区(depth buffer)。在OpenGL中我们可以像创建其他对象一样创建它:

// 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);

现在需要创建纹理,纹理中包含着色器的RGB输出。这段代码非常的经典:

// The texture we're going to render to
GLuint renderedTexture;
glGenTextures(1, &renderedTexture);

// "Bind" the newly created texture : all future texture functions will modify this texture
glBindTexture(GL_TEXTURE_2D, renderedTexture);

// Give an empty image to OpenGL ( the last "0" )
glTexImage2D(GL_TEXTURE_2D, 0,GL_RGB, 1024, 768, 0,GL_RGB, GL_UNSIGNED_BYTE, 0);

// Poor filtering. Needed !
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

同时还需要一个深度缓冲区(depth buffer)。这是可选的,取决于纹理中实际需要画的东西;由于我们渲染的是小猴Suzanne,所以需要深度测试。

// The depth buffer
GLuint depthrenderbuffer;
glGenRenderbuffers(1, &depthrenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, depthrenderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, 1024, 768);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthrenderbuffer);

最后,配置frameBuffer。

// Set "renderedTexture" as our colour attachement #0
glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, renderedTexture, 0);

// Set the list of draw buffers.
GLenum DrawBuffers[2] = {GL_COLOR_ATTACHMENT0};
glDrawBuffers(1, DrawBuffers); // "1" is the size of DrawBuffers

这个过程中可能出现一些错误,取决于GPU的性能;下面是检查的方法:

// Always check that our framebuffer is ok
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
    return false;

渲染到纹理

渲染到纹理很直观。简单地绑定帧缓存,然后像往常一样画场景。轻松搞定!

// Render to our framebuffer
glBindFramebuffer(GL_FRAMEBUFFER, FramebufferName);
glViewport(0,0,1024,768); // Render on the whole framebuffer, complete from the lower left corner to the upper right

fragment shader只需稍作调整:
layout(location = 0) out vec3 color;

这意味着每当修改变量“color”时,实际修改了0号渲染目标;这是因为之前调用了glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, renderedTexture, 0);

注意:最后一个参数表示mipmap的级别,这个0和GL_COLOR_ATTACHMENT0没有任何关系。

使用渲染出的纹理

我们将画一个简单的铺满屏幕的四边形。需要buffer、shader、ID……

// The fullscreen quad's FBO
GLuint quad_VertexArrayID;
glGenVertexArrays(1, &quad_VertexArrayID);
glBindVertexArray(quad_VertexArrayID);

static const GLfloat g_quad_vertex_buffer_data[] = {
    -1.0f, -1.0f, 0.0f,
    1.0f, -1.0f, 0.0f,
    -1.0f,  1.0f, 0.0f,
    -1.0f,  1.0f, 0.0f,
    1.0f, -1.0f, 0.0f,
    1.0f,  1.0f, 0.0f,
};

GLuint quad_vertexbuffer;
glGenBuffers(1, &quad_vertexbuffer);
glBindBuffer(GL_ARRAY_BUFFER, quad_vertexbuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(g_quad_vertex_buffer_data), g_quad_vertex_buffer_data, GL_STATIC_DRAW);

// Create and compile our GLSL program from the shaders
GLuint quad_programID = LoadShaders( "Passthrough.vertexshader", "SimpleTexture.fragmentshader" );
GLuint texID = glGetUniformLocation(quad_programID, "renderedTexture");
GLuint timeID = glGetUniformLocation(quad_programID, "time");

现在想渲染到屏幕上的话,必须把glBindFramebuffer的第二个参数设为0。

// Render to the screen
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glViewport(0,0,1024,768); // Render on the whole framebuffer, complete from the lower left corner to the upper right

我们用下面这个shader来画全屏的四边形:

#version 330 core

in vec2 UV;
out vec3 color;

uniform sampler2D renderedTexture;
uniform float time;

void main(){
    color = texture( renderedTexture, UV + 0.005*vec2( sin(time+1024.0*UV.x),cos(time+768.0*UV.y)) ).xyz;
}

这段代码只是简单地采样纹理,加上一个随时间变化的微小偏移。

结果

wavvy-1024x793

进一步探索

使用深度

在一些情况下,使用已渲染的纹理可能需要深度。本例中,像下面这样,简单地渲染到纹理中:

glTexImage2D(GL_TEXTURE_2D, 0,GL_DEPTH_COMPONENT24, 1024, 768, 0,GL_DEPTH_COMPONENT, GL_FLOAT, 0);

(“24”是精度。你可以按需从16,24,32中选。通常24刚好)

上面这些已经足够您起步了。课程源码中有完整的实现。

运行可能有点慢,因为驱动无法使用Hi-Z这类优化。

下图的深度层次已经经过手动“优化”。通常,深度纹理不会这么清晰。深度纹理中,近 = Z接近0 = 颜色深; 远 = Z接近1 = 颜色浅。

wavvydepth-1024x793

多重采样

能够用多重采样纹理来替代基础纹理:只需要在C++代码中将glTexImage2D替换为glTexImage2DMultisample,在fragment shader中将sampler2D/texture替换为sampler2DMS/texelFetch。

但要注意:texelFetch多出了一个参数,表示采样的数量。换句话说,就是没有自动“滤波”(在多重采样中,正确的术语是“分辨率(resolution)”)功能。

所以需要你自己解决多重采样的纹理,另外,非多重采样纹理,是多亏另一个着色器。

没有什么难点,只是体积庞大。

多重渲染目标

你可能需要同时写多个纹理。

简单地创建若干纹理(都要有正确、一致的大小!),调用glFramebufferTexture,为每一个纹理设置一个不同的color attachement,用更新的参数(如(2,{GL_COLOR_ATTACHMENT0,GL_COLOR_ATTACHMENT1,GL_DEPTH_ATTACHMENT})一样)调用glDrawBuffers,然后在片断着色器中多添加一个输出变量:

layout(location = 1) out vec3 normal_tangentspace; // or whatever

提示1:如果真需要在纹理中输出向量,浮点纹理也是有的,可以用16或32位精度代替8位……看看glTexImage2D的参考手册(搜GL_FLOAT)。
提示2:对于以前版本的OpenGL,请使用glFragData[1] = myvalue。

练习

  • 试使用glViewport(0,0,512,768)代替glViewport(0,0,1024,768);(帧缓存、屏幕两种情况都试试)
  • 在最后一个fragment shader中尝试一下用其他UV坐标
  • 试用一个真正的变换矩阵变换四边形。首先用硬编码方式。然后尝试使用controls.hpp里面的函数,观察到了什么现象?

© http://www.opengl-tutorial.org/

Written with StackEdit.

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%2013%20Normal%20Mapping%20opengl-tutorial.org.md

欢迎来到第十三课!今天讲法线贴图(normal mapping)。

学完第八课:基本光照模型后,我们知道了如何用三角形法线得到不错的光照效果。需要注意的是,截至目前,每个顶点仅有一个法线:在三角形三个顶点间,法线是平滑过渡的;而颜色(纹理的采样)恰与此相反。

法线纹理

法线纹理看起来像这样:

normal

每个纹素的RGB值实际上表示的是XYZ向量:颜色的分量取值范围为0到1,而向量的分量取值范围是-1到1;可以建立从纹素到法线的简单映射:

normal = (2*color)-1 // on each component

法线纹理整体呈蓝色,因为法线基本是朝上的(上方即Z轴正向。OpenGL中Y轴=上,有所不同。这种不兼容很蠢,但没人想为此重写现有的工具,我们将就用吧。后面介绍详情。)

法线纹理的映射方式和颜色纹理相似。麻烦的是如何将法线从各三角形局部坐标系(切线坐标系tangent space,亦称图像坐标系image space)变换到模型坐标系(计算光照采用的坐标系)。

切线和双切线(Tangent and Bitangent)

想必大家对矩阵已经十分熟悉了;大家知道,定义一个坐标系(本例是切线坐标系)需要三个向量。现在Up向量已经有了,即法线:可用Blender计算,或做一个简单的叉乘。下图中蓝色箭头代表法线(法线贴图整体颜色也恰好是蓝色)。

NormalVector

然后是切线T:垂直于平面的向量。切线有很多个:

TangentVectors

这么多切线中该选哪一个呢?理论上,任何一个都可以。不过我们得和相邻顶点保持一致,以免导致边缘出现瑕疵。一个通行的办法是将切线方向和纹理坐标系对齐:

TangentVectorFromUVs

定义一组基需要三个向量,因此我们还得计算双切线B(本来可以随便选一条切线,但选定垂直于其他两条轴的切线,计算会方便些)。

NTBFromUVs

算法如下:若把三角形的两条边记为deltaPos1和deltaPos2,deltaUV1和deltaUV2是对应的UV坐标下的差值;此问题可用如下方程表示:

deltaPos1 = deltaUV1.x * T + deltaUV1.y * B
deltaPos2 = deltaUV2.x * T + deltaUV2.y * B

求解T和B就得到了切线和双切线!(代码见下文)

已知T、B、N向量之后,即可得下面这个漂亮的矩阵,完成从模型坐标系到切线坐标系的变换:

TBN

有了TBN矩阵,我们就能把法线(从法线纹理中提取而来)变换到模型坐标系。

可我们需要的却是与之相反的变换:从切线坐标系到模型坐标系,法线保持不变。所有计算均在切线坐标系中进行,不会对其他计算产生影响。

既然要进行逆向的变换,那只需对以上矩阵求逆即可。这个矩阵(正交阵,即各向量相互正交,请看后面“延伸阅读”小节)的逆矩阵恰好也就是其转置矩阵,计算十分简单:

invTBN = transpose(TBN)


亦即:

transposeTBN

准备VBO

计算切线和双切线

我们需要为整个模型计算切线、双切线和法线。用一个单独的函数完成这项工作:

void computeTangentBasis(
    // inputs
    std::vector & vertices,
    std::vector & uvs,
    std::vector & normals,
    // outputs
    std::vector & tangents,
    std::vector & bitangents
){

为每个三角形计算边(deltaPos)和deltaUV

    for ( int i=0; i

现在用公式来算切线和双切线:

    float r = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x);
    glm::vec3 tangent = (deltaPos1 * deltaUV2.y   - deltaPos2 * deltaUV1.y)*r;
    glm::vec3 bitangent = (deltaPos2 * deltaUV1.x   - deltaPos1 * deltaUV2.x)*r;

最后,把这些切线双切线缓存到数组。记住,还没为这些缓存的数据生成索引,因此每个顶点都有一份拷贝。

// Set the same tangent for all three vertices of the triangle.
// They will be merged later, in vboindexer.cpp
tangents.push_back(tangent);
tangents.push_back(tangent);
tangents.push_back(tangent);

// Same thing for binormals
bitangents.push_back(bitangent);
bitangents.push_back(bitangent);
bitangents.push_back(bitangent);

}

生成索引

索引VBO的方法和之前类似,仅有些许不同。

若找到一个相似顶点(相同的坐标、法线、纹理坐标),我们不使用它的切线、次法线;反而要取其均值。因此,只需把旧代码修改一下:

        // Try to find a similar vertex in out_XXXX
        unsigned int index;
        bool found = getSimilarVertexIndex(in_vertices[i], in_uvs[i], in_normals[i],     out_vertices, out_uvs, out_normals, index);

        if ( found ){ // A similar vertex is already in the VBO, use it instead !
            out_indices.push_back( index );

            // Average the tangents and the bitangents
            out_tangents[index] += in_tangents[i];
            out_bitangents[index] += in_bitangents[i];
        }else{ // If not, it needs to be added in the output data.
            // Do as usual
            [...]
        }

注意,这里没做规范化。这样做很讨巧,因为小三角形的切线、双切线向量也小;相对于大三角形(对最终形状影响较大),对最终结果的影响力也就小。

Shader

新增的缓冲区和uniform变量

新加上两个缓冲区:分别存放切线和双切线:

GLuint tangentbuffer;
glGenBuffers(1, &tangentbuffer);
glBindBuffer(GL_ARRAY_BUFFER, tangentbuffer);
glBufferData(GL_ARRAY_BUFFER, indexed_tangents.size() * sizeof(glm::vec3), &indexed_tangents[0], GL_STATIC_DRAW);

GLuint bitangentbuffer;
glGenBuffers(1, &bitangentbuffer);
glBindBuffer(GL_ARRAY_BUFFER, bitangentbuffer);
glBufferData(GL_ARRAY_BUFFER, indexed_bitangents.size() * sizeof(glm::vec3), &indexed_bitangents[0], GL_STATIC_DRAW);

还需要一个uniform变量存储新的法线纹理:

    [...]
    GLuint NormalTexture = loadTGA_glfw("normal.tga");
    [...]
    GLuint NormalTextureID  = glGetUniformLocation(programID, "NormalTextureSampler");

另外一个uniform变量存储3x3的模型视图矩阵。严格地讲,这个矩阵不必要,但有它更方便;详见后文。由于仅仅计算旋转,不需要位移,因此只需矩阵左上角3x3的部分。

    GLuint ModelView3x3MatrixID = glGetUniformLocation(programID, "MV3x3");

完整的绘制代码如下:

// Clear the screen
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// Use our shader
glUseProgram(programID);

// Compute the MVP matrix from keyboard and mouse input
computeMatricesFromInputs();
glm::mat4 ProjectionMatrix = getProjectionMatrix();
glm::mat4 ViewMatrix = getViewMatrix();
glm::mat4 ModelMatrix = glm::mat4(1.0);
glm::mat4 ModelViewMatrix = ViewMatrix * ModelMatrix;
glm::mat3 ModelView3x3Matrix = glm::mat3(ModelViewMatrix); // Take the upper-left part of ModelViewMatrix
glm::mat4 MVP = ProjectionMatrix * ViewMatrix * ModelMatrix;

// Send our transformation to the currently bound shader,
// in the "MVP" uniform
glUniformMatrix4fv(MatrixID, 1, GL_FALSE, &MVP[0][0]);
glUniformMatrix4fv(ModelMatrixID, 1, GL_FALSE, &ModelMatrix[0][0]);
glUniformMatrix4fv(ViewMatrixID, 1, GL_FALSE, &ViewMatrix[0][0]);
glUniformMatrix4fv(ViewMatrixID, 1, GL_FALSE, &ViewMatrix[0][0]);
glUniformMatrix3fv(ModelView3x3MatrixID, 1, GL_FALSE, &ModelView3x3Matrix[0][0]);

glm::vec3 lightPos = glm::vec3(0,0,4);
glUniform3f(LightID, lightPos.x, lightPos.y, lightPos.z);

// Bind our diffuse texture in Texture Unit 0
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, DiffuseTexture);
// Set our "DiffuseTextureSampler" sampler to user Texture Unit 0
glUniform1i(DiffuseTextureID, 0);

// Bind our normal texture in Texture Unit 1
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, NormalTexture);
// Set our "Normal TextureSampler" sampler to user Texture Unit 0
glUniform1i(NormalTextureID, 1);

// 1rst attribute buffer : vertices
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, vertexbuffer);
glVertexAttribPointer(
0, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);

// 2nd attribute buffer : UVs
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, uvbuffer);
glVertexAttribPointer(
1, // attribute
2, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);

// 3rd attribute buffer : normals
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, normalbuffer);
glVertexAttribPointer(
2, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);

// 4th attribute buffer : tangents
glEnableVertexAttribArray(3);
glBindBuffer(GL_ARRAY_BUFFER, tangentbuffer);
glVertexAttribPointer(
3, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);

// 5th attribute buffer : bitangents
glEnableVertexAttribArray(4);
glBindBuffer(GL_ARRAY_BUFFER, bitangentbuffer);
glVertexAttribPointer(
4, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);

// Index buffer
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementbuffer);

// Draw the triangles !
glDrawElements(
GL_TRIANGLES, // mode
indices.size(), // count
GL_UNSIGNED_INT, // type
(void*)0 // element array buffer offset
);

glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
glDisableVertexAttribArray(3);
glDisableVertexAttribArray(4);

// Swap buffers
glfwSwapBuffers();

Vertex shader

和前面讲的一样,所有计算都在观察坐标系中做,因为在这获取片断坐标更容易。这就是为什么要用模型视图矩阵乘T、B、N向量。

    vertexNormal_cameraspace = MV3x3 * normalize(vertexNormal_modelspace);
    vertexTangent_cameraspace = MV3x3 * normalize(vertexTangent_modelspace);
    vertexBitangent_cameraspace = MV3x3 * normalize(vertexBitangent_modelspace);

这三个向量确定了TBN矩阵,其创建方式如下:

    mat3 TBN = transpose(mat3(
        vertexTangent_cameraspace,
        vertexBitangent_cameraspace,
        vertexNormal_cameraspace
    )); // You can use dot products instead of building this matrix and transposing it. See References for details.

此矩阵是从观察坐标系到切线坐标系的变换(若有一矩阵名为XXX_modelspace,则它执行的是从模型坐标系到切线坐标系的变换)。可以利用它计算切线坐标系中的光线方向和视线方向。

    LightDirection_tangentspace = TBN * LightDirection_cameraspace;
    EyeDirection_tangentspace =  TBN * EyeDirection_cameraspace;

Fragment shader

切线坐标系中的法线很容易获取:就在纹理中:

    // Local normal, in tangent space
    vec3 TextureNormal_tangentspace = normalize(texture2D( NormalTextureSampler, UV ).rgb*2.0 - 1.0);

一切准备就绪。漫反射光的值由切线坐标系中的n和l计算得来(在哪个坐标系中计算并不重要,重要的是n和l必须位于同一坐标系中),再用clamp( dot( n,l ), 0,1 )截断。镜面光用clamp( dot( E,R ), 0,1 )截断,E和R也必须位于同一坐标系中。搞定!S

结果

这是目前得到的结果,可以看到:

  • 砖块看上去凹凸不平,这是因为砖块表面法线变化比较剧烈
  • 水泥部分看上去很平整,这是因为这部分的法线纹理都是整齐的蓝色

normalmapping-1024x793

延伸阅读

正交化(Orthogonalization)

Vertex shader中,为了计算得更快,我们没有用矩阵求逆,而是进行了转置。这只有当矩阵表示的坐标系是正交的时候才成立,而眼前这个矩阵还不是正交的。幸运的是这个问题很容易解决:只需在computeTangentBasis()末尾让切线与法线垂直。I

t = glm::normalize(t - n * glm::dot(n, t));

这个公式有点难理解,来看看图:

gramshmidt

n和t差不多是相互垂直的,只要把t沿-n方向稍微“压”一下,这个幅度是dot(n,t)。
这里有一个applet也讲得很清楚(仅含两个向量)

左手坐标系还是右手坐标系?

一般不必担心这个问题。但在某些情况下,比如使用对称模型时,UV坐标方向是错的,导致切线T方向错误。

检查是否需要翻转这些方向很容易:TBN必须形成一个右手坐标系,即,向量cross(n,t)应该和b同向。

用数学术语讲,“向量A和向量B同向”就是“dot(A,B)>0”;故只需检查dot( cross(n,t) , b )是否大于0。

若dot( cross(n,t) , b ) < 0,就要翻转t:

if (glm::dot(glm::cross(n, t), b) < 0.0f)
{
    t = t * -1.0f;
 }

在computeTangentBasis()末对每个顶点都做这个操作。

高光纹理(Specular texture)

纯粹出于乐趣,我在代码里加上了高光纹理;取代了原先作为高光颜色的灰色vec3(0.3,0.3,0.3),现在看起来像这样:

specular

normalmappingwithspeculartexture-1024x793

注意,现在水泥部分始终是黑色的:因为高光纹理中,其高光分量为0。

用立即模式进行调试

本站的初衷是让大家不再使用过时、缓慢、问题频出的立即模式。

不过,用立即模式进行调试却十分方便:

immediatemodedebugging-1024x793

这里,我们在立即模式下画了一些线条表示切线坐标系。

要进入立即模式,得关闭3.3 Core Profile:

glfwOpenWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE);

然后把矩阵传给旧式的OpenGL流水线(你也可以另写一个着色器,不过这样做更简单,反正都是在hacking):
glMatrixMode(GL_PROJECTION);
glLoadMatrixf((const GLfloat*)&ProjectionMatrix[0]);
glMatrixMode(GL_MODELVIEW);
glm::mat4 MV = ViewMatrix * ModelMatrix;
glLoadMatrixf((const GLfloat*)&MV[0]);

禁用着色器:
glUseProgram(0);

然后画线条(本例中法线都已被归一化,乘了0.1,放到了对应顶点上):
glColor3f(0,0,1);
glBegin(GL_LINES);
for (int i=0; i
记住:实际项目中不要用立即模式!只在调试时用!别忘了之后恢复到Core Profile,它可以保证不会启用立即模式!

用颜色进行调试

调试时,将向量的值可视化很有用。最简单的方法是把向量都写到帧缓冲区。举个例子,我们把LightDirection_tangentspace可视化一下试试

color.xyz = LightDirection_tangentspace;

colordebugging-1024x793

这说明:

  • 在圆柱体的右侧,光线(如白色线条所示)是朝上(在切线坐标系中)的。也就是说,光线和三角形的法线同向。

  • 在圆柱体的中间部分,光线和切线方向(指向+X)同向。

友情提示:

  • 可视化前,变量是否需要规范化?这取决于具体情况。
  • 如果结果不好看懂,就逐分量地可视化。比如,只观察红色,而将绿色和蓝色分量强制设为0。
  • 别折腾alpha值,太复杂了icon_smile>
  • 若想将一个负值可视化,可以采用和处理法线纹理一样的技巧:转而把(v+1.0)/2.0可视化,于是黑色就代表-1,而白色代表+1。只不过这样做有点绕弯子。

用变量名进行调试

前面已经讲过了,搞清楚向量所处的坐标系至关重要。千万别把一个观察坐标系里的向量和一个模型坐标系里的向量做点乘。

给向量名称添加后缀“_modelspace”可以有效地避免这类计算错误。

怎样制作法线贴图

作者James O’Hare。点击图片放大。

normalMapMiniTut-320x1024

练习

  • 在indexVBO_TBN函数中,在做加法前把向量归一化,看看结果。
  • 用颜色可视化其他向量(如instance、EyeDirection_tangentspace),试着解释你看到的结果。

工具和链接

参考文献

OpenGL3.0教程 第十二课:OpenGL扩展


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%2011%202D%20text%20opengl-tutorial.org.md

扩展

GPU的性能随着更新换代一直在提高,支持渲染更多的三角形和像素点。然而,原始性能不是我们唯一关心的。NVIDIA, AMD和Intel也通过增加功能来改善他们的显卡。来看一些例子。

ARB_fragment_program

回溯到2002年,GPU都没有顶点着色器或片断着色器:所有的一切都硬编码在芯片中。这被称为固定功能流水线(Fixed-Function Pipeline (FFP))。同样地,当时最新的OpenGL 1.3中也没有接口可以创建、操作和使用所谓的“着色器”,因为它根本不存在。接着NVIDIA决定用实际代码描述渲染过程,来取代数以百计的标记和状态量。这就是ARB_fragment_program的由来。当时还没有GLSL,但你可以写这样的程序:

!!ARBfp1.0 MOV result.color, fragment.color; END

但若要显式地令OpenGL使用这些代码,你需要一些还不在OpenGL里的特殊函数。在进行解释前,再举个例子。

ARB_debug_output

好,你说『ARB_fragment_program太老了,所以我不需要扩展这东西』?其实有不少新的扩展非常方便。其中一个便是ARB_debug_output,它提供了一个不存在于OpenGL 3.3中的,但你可以/应该用到的功能。它定义了像GL_DEBUG_OUTPUT_SYNCHRONOUS_ARB或GL_DEBUG_SEVERITY_MEDIUM_ARB之类的字符串,和DebugMessageCallbackARB这样的函数。这个扩展的伟大之处在于,当你写了一些不正确的代码,例如:

glEnable(GL_TEXTURE); // Incorrect ! You probably meant GL_TEXTURE_2D !

你能得到错误消息和错误的精确位置。总结:

  • 即便在现在的OpenGL 3.3中,扩展仍旧十分有用。
  • 请使用ARB_debug_output !下文有链接。

获取扩展 – 复杂的方式

『手动』查找一个扩展的方法是使用以下代码片断 (转自OpenGL.org wiki):

int NumberOfExtensions;
glGetIntegerv(GL_NUM_EXTENSIONS, &NumberOfExtensions);
for(i=0; i

获得所有的扩展 – 简单的方式

上面的方式太复杂。若用GLEW, GLee, gl3w这些库,就简单多了。例如,有了GLEW,你只需要在创建窗口后调用glewInit(),不少方便的变量就创建好了:

if (GLEW_ARB_debug_output){ // Ta-Dah ! }

(小心:debug_output是特殊的,因为你需要在上下文创建的时候启用它。在GLFW中,这通过glfwOpenWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, 1)完成。)

ARB vs EXT vs …

扩展的名字暗示了它的适用范围:

GL:所有平台; GLX:只有Linux和Mac下可使用(X11);
WGL_:只有Windows下可使用。

EXT:通用的扩展。
ARB:已经被OpenGL架构评审委员会的所有成员接受(EXT扩展没多久后就经常被提升为ARB)的扩展。
NV/AMD/INTEL:顾名思义 =)

设计与扩展

问题

比方说,你的OpenGL 3.3应用程序需要渲染一些大型线条。你能够写一个复杂的顶点着色器来完成,或者简单地用GL_NV_path_rendering,它能帮你处理所有复杂的事。

因此你可以这样写代码:

if ( GLEW_NV_path_rendering ){
    glPathStringNV( ... ); // Draw the shape. Easy !
}else{
    // Else what ? You still have to draw the lines
    // on older NVIDIA hardware, on AMD and on INTEL !
    // So you have to implement it yourself anyway !
}

均衡考量

当使用扩展的益处(如渲染质量、性能),超过维护两种不同方法(如上面的代码,一种靠你自己实现,一种使用扩展)的代价时,通常就选择用扩展。

例如,在时空幻境(Braid, 一个时空穿越的二维游戏)中,当你干扰时间时,就会有各种各样的图像变形效果,而这种效果在旧硬件上没法渲染。

而在OpenGL 3.3及更高版本中,包含了99%的你可能会用到的工具。一些扩展很有用,比如GL_AMD_pinned_memory,虽然它通常没法像几年前使用GL_ARB_framebuffer_object(用于纹理渲染)那样让你的游戏看起来变好10倍。

如果你不得不兼容老硬件,那么就不能用OpenGL 3+,你需要用OpenGL 2+来代替。你将不再能使用各种神奇的扩展了,你需自行处理那些问题。

更多的细节可以参考例子OpenGL 2.1版本的第14课 – 纹理渲染,第152行,需手动检查GL_ARB_framebuffer_object是否存在。常见问题可见FAQ。

结论Conclusion

OpenGL扩展提供了一个很好的方式来增强OpenGL的功能,它依赖于你用户的GPU。

虽然现在扩展属于高级用法(因为大部分功能在核心中已经有了),了解扩展如何运作和怎么用它提高软件性能(付出更高的维护代价)还是很重要的。

深度阅读

  • debug_output tutorial by Aks 因为有GLEW,你可以跳过第一步。
  • The OpenGL extension registry 所有扩展的规格说明。圣经。
  • GLEW OpenGL标准扩展库
  • gl3w 简单的OpenGL 3/4核心配置加载

OpenGL3.0教程 第十一课:2D文本


OpenGL3.0教程

原文链接:http://www.opengl-tutorial.org/beginners-tutorials/tutorial-8-basic-shading/

原译文链接: https://github.com/cybercser/OpenGL_3_3_Tutorial_Translation/blob/master/Tutorial%2011%202D%20text%20opengl-tutorial.org.md

本课将学习如何在三维场景之上绘制二维文本。本例是一个简单的计时器:

clock-1024x793

API

我们将实现这些简单的接口(位于common/text2D.h):

    void initText2D(const char * texturePath);
    void printText2D(const char * text, int x, int y, int size);
    void cleanupText2D();

为了让代码在640*480和1080p分辨率下都能正常工作,x和y的范围分别设为[0-800]和[0-600]。顶点着色器将根据实际屏幕大小做对它做调整。

完整的实现代码请参阅common/text2D.cpp。

纹理

initText2D简单地读取一个纹理和一些着色器,很好理解。来看看纹理:

fontalpha-1024x717

该纹理由CBFG生成。CBFG是诸多从字体生成纹理的工具之一。把纹理加载到Paint.NET,加上红色背景(仅为了观察方便;本教程中的红色背景,都代表透明)。

printText2D()在屏幕的适当位置,生成一个纹理坐标正确的四边形。

绘制

首先,填充这些缓冲区:

    std::vector vertices;
    std::vector UVs;

文本中的每个字母,都要计算其四边形包围盒的顶点坐标,然后添加两个三角形(组成一个四边形):

for ( unsigned int i=0 ; i<length ; i++ ){
    glm::vec2 vertex_up_left??? = glm::vec2( x+i*size???? , y+size );
    glm::vec2 vertex_up_right?? = glm::vec2( x+i*size+size, y+size );
    glm::vec2 vertex_down_right = glm::vec2( x+i*size+size, y????? );
    glm::vec2 vertex_down_left? = glm::vec2( x+i*size???? , y????? );

    vertices.push_back(vertex_up_left?? );
    vertices.push_back(vertex_down_left );
    vertices.push_back(vertex_up_right? );

    vertices.push_back(vertex_down_right);
    vertices.push_back(vertex_up_right);
    vertices.push_back(vertex_down_left);

轮到UV坐标了。计算左上角的坐标:

    char character = text[i];
    float uv_x = (character%16)/16.0f;
    float uv_y = (character/16)/16.0f;

这样做是可行的(基本可行,详见下文),因为A的ASCII值为65。
65%16 = 1,因此A位于第1列(列号从0开始)。

65/16 = 4,因此A位于第4行(这是整数除法,所以结果不是想象中的4.0625)

两者都除以16.0以使之落于[0.0 - 1.0]区间内,这正是OpenGL纹理所需的。

现在只需对顶点重复相同的操作:

    glm::vec2 uv_up_left    = glm::vec2( uv_x           , 1.0f - uv_y );
    glm::vec2 uv_up_right   = glm::vec2( uv_x+1.0f/16.0f, 1.0f - uv_y );
    glm::vec2 uv_down_right = glm::vec2( uv_x+1.0f/16.0f, 1.0f - (uv_y + 1.0f/16.0f) );
    glm::vec2 uv_down_left  = glm::vec2( uv_x           , 1.0f - (uv_y + 1.0f/16.0f) );

    UVs.push_back(uv_up_left   );
    UVs.push_back(uv_down_left );
    UVs.push_back(uv_up_right  );

    UVs.push_back(uv_down_right);
    UVs.push_back(uv_up_right);
    UVs.push_back(uv_down_left);
}


其余的操作和往常一样:绑定缓冲区,填充,选择着色器程序,绑定纹理,开启、绑定、配置顶点属性,开启混合,调用glDrawArrays。欧也,搞定了。

注意非常重要的一点:这些坐标位于[0,800][0,600]范围内。也就是说,这里不需要矩阵。vertex shader只需简单换算就可以把这些坐标转换到[-1,1][-1,1]范围内(也可以在C++代码中完成这一步)。

void main()
{
    // Output position of the vertex, in clip space
    // map [0..800][0..600] to [-1..1][-1..1]
    vec2 vertexPosition_homoneneousspace = vertexPosition_screenspace - vec2(400,300); // [0..800][0..600] -> [-400..400][-300..300]
    vertexPosition_homoneneousspace /= vec2(400,300);
    gl_Position =  vec4(vertexPosition_homoneneousspace,0,1);

    // UV of the vertex. No special space for this one.
    UV = vertexUV;
}

fragment shader的工作也很少:

void main()
{
    color = texture( myTextureSampler, UV );
}

顺便说一下,别在工程中使用这些代码,因为它只能处理拉丁字符。否则你的产品在印度、中国、日本(甚至德国,因为纹理上没有ß这个字母)就别想卖了。这幅纹理是我用法语字符集生成的,在法国用用还可以(注意 é, à, ç等字母)。修改其他教程的代码时注意库的使用。其他教程大多使用OpenGL 2,和本教程不兼容。很可惜,我还没找到一个足够好的、能处理UTF-8字符集的库。

顺带提一下,您最好看看Joel Spolsky写的The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)

如果您需要处理大量的文本,可以参考这篇Valve的文章

OpenGL3.0教程 第十课:透明


OpenGL3.0教程

原文链接:http://www.opengl-tutorial.org/beginners-tutorials/tutorial-8-basic-shading/

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

alpha通道

alpha通道的概念很简单。之前是写RGB结果,现在改为写RGBA:

    // Ouput data : it's now a vec4
    out vec4 color;

前三个分量仍可以通过混合操作符(swizzle operator).xyz访问,最后一个分量通过.a访问:

    color.a = 0.3;

不太直观,但alpha = 不透明度;因此alpha = 1代表完全不透明,alpha = 0为完全透明。

这里我们简单地将alpha硬编码为0.3;但更常见的做法是用一个uniform变量表示它,或从RGBA纹理中读取(TGA格式支持alpha通道,而GLFW支持TGA)。

结果如下。既然我们能“看透”模型表面,请确保关闭隐面消除(glDisable(GL_CULL_FACE))。否则就发现模型没有了“背”面。
transparencyok-1024x793

顺序很重要!

上一个截图看上去还行,但这仅仅是运气好罢了。

问题所在

这里我画了一红一绿两个alpha值为50%的正方形。从中可以看出顺序的重要性,最终的颜色显著影响了眼睛对深度的感知。

transparencyorder

我们的场景中也出现了同样的现象。试着稍稍改变一下视角:

transparencybad-1024x793

事实证明这个问题十分棘手。游戏中透明的东西不多,对吧?

常见解决方案

常见解决方案即对所有的透明三角形排序。是的,所有的透明三角形。

  • 绘制场景的不透明部分,让深度缓冲区能丢弃被遮挡的透明三角形。
  • 对透明三角形按深度从近到远排序。
  • 绘制透明三角形。

可以用C语言的qsort函数或者C++的std::sort函数来排序。细节就不多说了,因为……

警告

这么做可以解决问题(下一节还会介绍它),但:

  • 填充速率会被限制,即,每个片断会写10、20次,也许更多。这对力不从心的内存总线来说太沉重了。通常,深度缓冲区可以自动丢弃“远”片断;但这时,我们显式地对片断进行排序,故深度缓冲区实际上没发挥作用。
  • 这些操作,每个像素上都会做4遍(我们用了4倍多重采样抗锯齿(MSAA)),除非用了什么高明的优化。
  • 透明三角形排序很耗时
  • 若要逐个三角形地切换纹理,或者更糟糕地,要切换着色器——性能会大打折扣。别这么干。

一个足够好的解决方案是:

  • 限制透明多边形的数量
  • 对所有透明多边形使用同一个着色器和纹理
  • 若这些透明多边形必须看起来很不同,请用纹理区分!
  • 若不排序,效果也还行,那最好别排序。

顺序无关透明

如果你的引擎确实需要顶尖的透明效果,这有一些技术值得研究一番:

注意,即便是《小小大星球》(Little Big Planet)这种最新的端游,也只用了一层透明。

混合函数

要让之前的代码运行,得设置好混合函数。In order for the previous code to work, you need to setup your blend function.

    // Enable blending
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

这意味着

New color in framebuffer = 
           current alpha in framebuffer * current color in framebuffer + 
           (1 - current alpha in framebuffer) * shader's output color

前文所述红色方块居上的例子中:

new color = 0.5*(0,1,0) + (1-0.5)*(1,0.5,0.5); // (the red was already blended with the white background)
new color = (1, 0.75, 0.25) = the same orange

OpenGL3.0教程 第九课:VBO索引


OpenGL3.0教程

原文链接:http://www.opengl-tutorial.org/beginners-tutorials/tutorial-8-basic-shading/

原译文链接: https://github.com/cybercser/OpenGL_3_3_Tutorial_Translation/blob/master/Tutorial%209%20VBO%20Indexing%20opengl-tutorial.org.md

索引的原理

目前为止,建立VBO时我们总是重复存储一些共享的顶点和边。

本课将介绍索引技术。借助索引,我们可以重复使用一个顶点。这是用索引缓冲区(index buffer)来实现的。

indexing1.png

索引缓冲区存储的是整数;每个三角形有三个整数索引,用索引就可以在各种属性缓冲区(顶点坐标、颜色、UV坐标、其他UV坐标、法向缓冲区等)中找到顶点的信息。这有点像OBJ文件格式,但有一点相差甚远:索引缓冲区只有一个。这意味着若两个三角形共用一个顶点,那这个顶点的所有属性对两个三角形来说都是一样的。

共享vs分开

来看看法向的例子。下图中,艺术家创建了两个三角形,试图模拟一个平滑曲面。可以把两个三角形的法向融合成一个顶点的法向。为方便观看,我画了一条红线表示平滑曲面。

goodsmooth

然而在第二幅图中,美工想画的是“缝隙”或“边缘”。若融合了法向,就意味着色器会像前例一样进行平滑插值,生成一个平滑的表面:

badmooth

因此在这种情况下,把顶点的法向分开存储反而更好;在OpenGL中,唯一实现方法是:把顶点连同其属性完整复制一份。

spiky

OpenGL中的索引VBO

索引的用法很简单。首先,需要创建一个额外的缓冲区存放索引。代码与之前一样,不过参数是ELEMENT_ARRAY_BUFFER,而非ARRAY_BUFFER。

std::vector indices;
// fill "indices" as needed

// Generate a buffer for the indices
 GLuint elementbuffer;
 glGenBuffers(1, &elementbuffer);
 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementbuffer);
 glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), &indices[0], GL_STATIC_DRAW);

只需把`glDrawArrays`替换为如下语句,即可绘制模型:

    // Index buffer
     glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementbuffer);

     // Draw the triangles !
     glDrawElements(
         GL_TRIANGLES,      // mode
         indices.size(),    // count
         GL_UNSIGNED_INT,   // type
         (void*)0           // element array buffer offset
     );

(小提示:最好使用unsigned short,不要用unsigned int。这样更节省空间,速度也更快。)

填充索引缓冲区

现在遇到真正的问题了。如前所述,OpenGL只能使用一个索引缓冲区,而OBJ(及一些其他常用的3D格式,如Collada)每个属性都有一个索引缓冲区。这意味着,必须通过某种方式把若干个索引缓冲区合并成一个。

合并算法如下:

For each input vertex
    Try to find a similar ( = same for all attributes ) vertex between all those we already output
    If found :
        A similar vertex is already in the VBO, use it instead !
    If not found :
        No similar vertex found, add it to the VBO

完整的C++代码位于common/vboindexer.cpp,注释很详尽。如果理解了以上算法,读懂代码应该没问题。

若两顶点的坐标、UV坐标和法线都相等,则认为两顶点是同一顶点。若还有其他属性,这一标准得酌情修改。

为了表述的简单,我们采用了蹩脚的线性查找来寻找相似顶点。实际中用std::map会更好。

补充:FPS计数器

虽然和索引没有直接关系,但现在去看看“FPS计数器”是很合适的——这样我们就能看到,索引究竟能提升多少性能。“工具——调试器”中还有些其他和性能相关的工具。

OpenGL3.0教程 第八课:基础光照模型


OpenGL3.0教程

原文链接:http://www.opengl-tutorial.org/beginners-tutorials/tutorial-8-basic-shading/

原译文链接: http://www.opengl-tutorial.org/zh-hans/beginners-tutorials-zh/tutorial-8-basic-shading-zh/

在第八课中,我们将学习光照模型的基础知识。包括:

  • 物体离光源越近会越亮
  • 直视反射光时会有高亮(镜面反射)
  • 当光没有直接照射物体时,物体会更暗(漫反射)
  • 用环境光简化计算

不包括:

  • 阴影。这是个宽阔的主题,大到需要专题教程了。
  • 类镜面反射(包括水)
  • 任何复杂的光与物质的相互作用,像次表面散射(比如蜡)
  • 各向异性材料(比如拉丝的金属)
  • 追求真实感的,基于物理的光照模型
  • 环境光遮蔽(在洞穴里会更黑)
  • 颜色溢出(一块红色的地毯会映得白色天花板带红色)
  • 透明度
  • 任何种类的全局光照(它包括了上面的所有)

总而言之:只讲基础。

法向

过去的几个教程中我们一直在处理法向,但是并不知道法向到底是什么。

三角形法向

一个平面的法向是一个长度为1并且垂直于这个平面的向量。
一个三角形的法向是一个长度为1并且垂直于这个三角形的向量。通过简单地将三角形两条边进行叉乘计算(向量a和b的叉乘结果是一个同时垂直于a和b的向量,记得?),然后归一化:使长度为1。伪代码如下:

triangle ( v1, v2, v3 )
edge1 = v2-v1
edge2 = v3-v1
triangle.normal = cross(edge1, edge2).normalize()

不要将法向(normal)和normalize()函数混淆。Normalize()函数是让一个向量(任意向量,不一定必须是normal)除以其长度,从而使新长度为1。法向(normal)则是某一类向量的名字。

顶点法向

引申开来:顶点的法向,是包含该顶点的所有三角形的法向的均值。这很方便——因为在顶点着色器中,我们处理顶点,而不是三角形;所以在顶点处有信息是很好的。并且在OpenGL中,我们没有任何办法获得三角形信息。伪代码如下:

vertex v1, v2, v3, ....
triangle tr1, tr2, tr3 // all share vertex v1
v1.normal = normalize( tr1.normal + tr2.normal + tr3.normal )

在OpenGL中使用顶点法向

在OpenGL中使用法向很简单。法向是顶点的属性,就像位置,颜色,UV坐标等一样;按处理其他属性的方式处理即可。第七课的loadOBJ函数已经将它们从OBJ文件中读出来了。

GLuint normalbuffer;
glGenBuffers(1, &normalbuffer);
glBindBuffer(GL_ARRAY_BUFFER, normalbuffer);
glBufferData(GL_ARRAY_BUFFER, normals.size() * sizeof(glm::vec3), &normals[0], GL_STATIC_DRAW);

// 3rd attribute buffer : normals
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, normalbuffer);
glVertexAttribPointer(
2, // attribute
3, // size
GL_FLOAT, // type
GL_FALSE, // normalized?
0, // stride
(void*)0 // array buffer offset
);

有这些准备就可以开始了。

漫反射部分

表面法向的重要性

当光源照射一个物体,其中重要的一部分光向各个方向反射。这就是“漫反射分量”。(我们不久将会看到光的其他部分去哪里了)

当一定量的光线到达某表面,该表面根据光到达时的角度而不同程度地被照亮。

如果光线垂直于表面,它会聚在一小片表面上。如果它以一个倾斜角到达表面,相同的强度光照亮更大一片表面:

这意味着在斜射下,表面的点会较黑(但是记住,更多的点会被照射到,总光强度仍然是一样的)

也就是说,当计算像素的颜色时,入射光和表面法向的夹角很重要。因此有:

// Cosine of the angle between the normal and the light direction,
// clamped above 0
// - light is at the vertical of the triangle -> 1
// - light is perpendicular to the triangle -> 0
float cosTheta = dot( n,l );

color = LightColor * cosTheta;

在这段代码中,n是表面法向,l是从表面到光源的单位向量(和光线方向相反。虽然不直观,但能简化数学计算)。

注意正负

求cosTheta的公式有漏洞。如果光源在三角形后面,n和l方向相反,那么n.l是负值。这意味着colour=一个负数,没有意义。因此这种情况须用clamp()将cosTheta赋值为0:

// Cosine of the angle between the normal and the light direction,
// clamped above 0
// - light is at the vertical of the triangle -> 1
// - light is perpendicular to the triangle -> 0
// - light is behind the triangle -> 0
float cosTheta = clamp( dot( n,l ), 0,1 );

color = LightColor * cosTheta;

材质颜色

当然,输出颜色也依赖于材质颜色。在这幅图像中,白光由绿、红、蓝光组成。当光碰到红色材质时,绿光和蓝光被吸收,只有红光保留着。

我们可以通过一个简单的乘法来模拟:

color = MaterialDiffuseColor * LightColor * cosTheta;

模拟光源

首先假设在空间中有一个点光源,它向所有方向发射光线,像蜡烛一样。

对于该光源,我们的表面收到的光通量依赖于表面到光源的距离:越远光越少。实际上,光通量与距离的平方成反比:

color = MaterialDiffuseColor * LightColor * cosTheta / (distance*distance);

最后,需要另一个参数来控制光的强度。它可以被编码到LightColor中(将在随后的课程中讲到),但是现在暂且只一个颜色值(如白色)和一个强度(如60瓦)。

color = MaterialDiffuseColor * LightColor * LightPower * cosTheta / (distance*distance);

组合在一起

为了让这段代码运行,需要一些参数(各种颜色和强度)和更多代码。

MaterialDiffuseColor简单地从纹理中获取。

LightColor和LightPower通过GLSL的uniform变量在着色器中设置。

cosTheta由n和l决定。我们可以在任意坐标系中表示它们,因为都是一样的。这里选相机坐标系,是因为它计算光源位置简单:

// Normal of the computed fragment, in camera space
vec3 n = normalize( Normal_cameraspace );
// Direction of the light (from the fragment to the light)
vec3 l = normalize( LightDirection_cameraspace );

Normal_cameraspace和LightDirection_cameraspace在顶点着色器中计算,然后传给片断着色器:

// Output position of the vertex, in clip space : MVP * position
gl_Position = MVP * vec4(vertexPosition_modelspace,1);

// Position of the vertex, in worldspace : M * position
Position_worldspace = (M * vec4(vertexPosition_modelspace,1)).xyz;

// Vector that goes from the vertex to the camera, in camera space.
// In camera space, the camera is at the origin (0,0,0).
vec3 vertexPosition_cameraspace = ( V * M * vec4(vertexPosition_modelspace,1)).xyz;
EyeDirection_cameraspace = vec3(0,0,0) - vertexPosition_cameraspace;

// Vector that goes from the vertex to the light, in camera space. M is ommited because it's identity.
vec3 LightPosition_cameraspace = ( V * vec4(LightPosition_worldspace,1)).xyz;
LightDirection_cameraspace = LightPosition_cameraspace + EyeDirection_cameraspace;

// Normal of the the vertex, in camera space
Normal_cameraspace = ( V * M * vec4(vertexNormal_modelspace,0)).xyz; // Only correct if ModelMatrix does not scale the model ! Use its inverse transpose if not.

这段代码看起来很牛,但它就是在第三课中学到的东西:矩阵。每个向量命名时,都嵌入了所在的空间名,这样在跟踪时更简单。 你也应该这样做。

M和V分别是模型和视图矩阵,并且是用与MVP完全相同的方式传给着色器。

运行时间

现在有了编写漫反射光源的一切必要条件。向前吧,刻苦努力地尝试

结果

只包含漫反射分量时,我们得到以下结果(再次为无趣的纹理道歉):

这次结果比之前好,但感觉仍少了一些东西。特别地,Suzanne的背后完全是黑色的,因为我们使用clamp()。

环境光分量

环境光分量是最华丽的优化。

我们期望的是Suzanne的背后有一点亮度,因为在现实生活中灯泡会照亮它背后的墙,而墙会反过来(微弱地)照亮物体的背后。

但计算它的代价大得可怕。

因此通常可以简单地做点假光源取巧。实际上,直接让三维模型发光,使它看起来不是完全黑即可。

可这样完成:

vec3 MaterialAmbientColor = vec3(0.1,0.1,0.1) * MaterialDiffuseColor;

color =
// Ambient : simulates indirect lighting
MaterialAmbientColor +
// Diffuse : "color" of the object
MaterialDiffuseColor * LightColor * LightPower * cosTheta / (distance*distance) ;

来看看它的结果

结果

好的,效果更好些了。如果要更好的结果,可以调整(0.1, 0.1, 0.1)值。

镜面反射分量

反射光的剩余部分就是镜面反射分量。这部分的光在表面有确定的反射方向。


如图所示,它形成一种波瓣。在极端的情况下,漫反射分量可以为零,这样波瓣非常非常窄(所有的光从一个方向反射),这就是镜子。

(的确可以调整参数值,得到镜面;但这个例子中,镜面唯一反射的只有光源,渲染结果看起来会很奇怪)

// Eye vector (towards the camera)
vec3 E = normalize(EyeDirection_cameraspace);
// Direction in which the triangle reflects the light
vec3 R = reflect(-l,n);
// Cosine of the angle between the Eye vector and the Reflect vector,
// clamped to 0
// - Looking into the reflection -> 1
// - Looking elsewhere -> < 1
float cosAlpha = clamp( dot( E,R ), 0,1 );

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

R是反射光的方向,E是视线的反方向(就像之前对“l”的假设);如果二者夹角很小,意味着视线与反射光线重合。

pow(cosAlpha,5)用来控制镜面反射的波瓣。可以增大5来获得更大的波瓣。

最终结果


注意到镜面反射使鼻子和眉毛更亮。

这个光照模型因为简单,已被使用了很多年。但它有一些问题,所以被microfacet BRDF之类的基于物理的模型代替,后面将会讲到。

在下节课中,我们将学习怎么提高VBO的性能。将是第一节中级课程!

OpenGL3.0教程 第七课:模型加载


OpenGL3.0教程

原文链接:http://www.opengl-tutorial.org/beginners-tutorials/tutorial-7-model-loading/

原译文链接: http://www.opengl-tutorial.org/zh-hans/beginners-tutorials-zh/tutorial-7-model-loading-zh/

目前为止,我们一直在硬编码描述立方体。你一定觉得这样做很笨拙、不方便。

本课将学习从文件中加载3D模型。和加载纹理类似,我们先写一个小的、功能有限的加载器,接着再为大家介绍几个比我们写的更好的、实用的库。

为了让课程尽可能简单,我们将采用简单、常用的OBJ格式。同样也是出于简单原则,我们只处理每个顶点有一个UV坐标和一个法向量的OBJ文件(目前你不需要知道什么是法向量)。

加载OBJ模型

加载函数在common/objloader.hpp中声明,在common/objloader.cpp中实现。函数原型如下:

bool loadOBJ(
    const char * path,
    std::vector  & out_vertices,
    std::vector  & out_uvs,
    std::vector  & out_normals
)

我们让loadOBJ读取文件路径,把数据写入out_vertices/out_uvs/out_normals。如果出错则返回false。std::vector是C++中的数组,可存放glm::vec3类型的数据,数组大小可任意修改,不过std::vector和数学中的向量(vector)是两码事。其实它只是个数组。最后提一点,符号&意思是这个函数将会直接修改这些数组。

OBJ文件示例

OBJ文件看起来大概像这样:

    # Blender3D v249 OBJ File: untitled.blend
    # www.blender3d.org
    mtllib cube.mtl
    v 1.000000 -1.000000 -1.000000
    v 1.000000 -1.000000 1.000000
    v -1.000000 -1.000000 1.000000
    v -1.000000 -1.000000 -1.000000
    v 1.000000 1.000000 -1.000000
    v 0.999999 1.000000 1.000001
    v -1.000000 1.000000 1.000000
    v -1.000000 1.000000 -1.000000
    vt 0.748573 0.750412
    vt 0.749279 0.501284
    vt 0.999110 0.501077
    vt 0.999455 0.750380
    vt 0.250471 0.500702
    vt 0.249682 0.749677
    vt 0.001085 0.750380
    vt 0.001517 0.499994
    vt 0.499422 0.500239
    vt 0.500149 0.750166
    vt 0.748355 0.998230
    vt 0.500193 0.998728
    vt 0.498993 0.250415
    vt 0.748953 0.250920
    vn 0.000000 0.000000 -1.000000
    vn -1.000000 -0.000000 -0.000000
    vn -0.000000 -0.000000 1.000000
    vn -0.000001 0.000000 1.000000
    vn 1.000000 -0.000000 0.000000
    vn 1.000000 0.000000 0.000001
    vn 0.000000 1.000000 -0.000000
    vn -0.000000 -1.000000 0.000000
    usemtl Material_ray.png
    s off
    f 5/1/1 1/2/1 4/3/1
    f 5/1/1 4/3/1 8/4/1
    f 3/5/2 7/6/2 8/7/2
    f 3/5/2 8/7/2 4/8/2
    f 2/9/3 6/10/3 3/5/3
    f 6/10/4 7/6/4 3/5/4
    f 1/2/5 5/1/5 2/9/5
    f 5/1/6 6/10/6 2/9/6
    f 5/1/7 8/11/7 6/10/7
    f 8/11/7 7/12/7 6/10/7
    f 1/2/8 2/9/8 3/13/8
    f 1/2/8 3/13/8 4/14/8

因此:

  • 是注释标记,就像C++中的//
  • usemtl和mtlib描述了模型的外观。本课用不到。
  • v代表顶点
  • vt代表顶点的纹理坐标
  • vn代表顶点的法向
  • f代表面

v vt vn都很好理解。f比较麻烦。例如f 8/11/7 7/12/7 6/10/7:

  • 8/11/7描述了三角形的第一个顶点
  • 7/12/7描述了三角形的第二个顶点
  • 6/10/7描述了三角形的第三个顶点
  • 对于第一个顶点,8指向要用的顶点。此例中是-1.000000 1.000000 -1.000000(索引从1开始,和C++中从0开始不同)
  • 11指向要用的纹理坐标。此例中是0.748355 0.998230。
  • 7指向要用的法向。此例中是0.000000 1.000000 -0.000000。

我们称这些数字为索引。若几个顶点共用同一个坐标,索引就显得很方便,文件中只需保存一个“V”,可以多次引用,节省了存储空间。

不好的地方在于,我们不能让OpenGL混用顶点、纹理和法向索引。因此本课采用的方法是创建一个标准的、未加索引的模型。等第九课时再讨论索引,届时将会介绍如何解决OpenGL的索引问题。

用Blender创建OBJ文件

我们写的蹩脚加载器功能实在有限,因此在导出模型时得格外小心。下图展示了在Blender中导出模型的情形:

读取OBJ文件

OK,真正开始编码了。需要一些临时变量存储.obj文件的内容。

std::vector vertexIndices, uvIndices, normalIndices;
std::vector temp_vertices;
std::vector temp_uvs;
std::vector temp_normals;

学第五课纹理立方体时,你已学会如何打开文件了:

FILE * file = fopen(path, "r");
if( file == NULL ){
    printf("Impossible to open the file !n");
    return false;
}

读文件直到文件末尾:

while( 1 ){

    char lineHeader[128];
    // read the first word of the line
    int res = fscanf(file, "%s", lineHeader);
    if (res == EOF)
        break; // EOF = End Of File. Quit the loop.

    // else : parse lineHeader

(注意,我们假设第一行的文字长度不超过128,这样做太愚蠢了。但既然这只是个实验品,就凑合一下吧)

首先处理顶点:

if ( strcmp( lineHeader, "v" ) == 0 ){
    glm::vec3 vertex;
    fscanf(file, "%f %f %fn", &vertex.x, &vertex.y, &vertex.z );
    temp_vertices.push_back(vertex);

也就是说,若第一个字是“v”,则后面一定是3个float值,于是以这3个值创建一个glm::vec3变量,将其添加到数组。

}else if ( strcmp( lineHeader, "vt" ) == 0 ){
    glm::vec2 uv;
    fscanf(file, "%f %fn", &uv.x, &uv.y );
    temp_uvs.push_back(uv);

也就是说,如果不是“v”而是“vt”,那后面一定是2个float值,于是以这2个值创建一个glm::vec2变量,添加到数组。

以同样的方式处理法向:

}else if ( strcmp( lineHeader, "vn" ) == 0 ){
    glm::vec3 normal;
    fscanf(file, "%f %f %fn", &normal.x, &normal.y, &normal.z );
    temp_normals.push_back(normal);

接下来是“f”,略难一些:

}else if ( strcmp( lineHeader, "f" ) == 0 ){
    std::string vertex1, vertex2, vertex3;
    unsigned int vertexIndex[3], uvIndex[3], normalIndex[3];
    int matches = fscanf(file, "%d/%d/%d %d/%d/%d %d/%d/%dn", &vertexIndex[0], &uvIndex[0], &normalIndex[0], &vertexIndex[1], &uvIndex[1], &normalIndex[1], &vertexIndex[2], &uvIndex[2], &normalIndex[2] );
    if (matches != 9){
        printf("File can't be read by our simple parser : ( Try exporting with other optionsn");
        return false;
    }
    vertexIndices.push_back(vertexIndex[0]);
    vertexIndices.push_back(vertexIndex[1]);
    vertexIndices.push_back(vertexIndex[2]);
    uvIndices    .push_back(uvIndex[0]);
    uvIndices    .push_back(uvIndex[1]);
    uvIndices    .push_back(uvIndex[2]);
    normalIndices.push_back(normalIndex[0]);
    normalIndices.push_back(normalIndex[1]);
    normalIndices.push_back(normalIndex[2]);

代码与前面的类似,只不过读取的数据多一些。

处理数据

我们只需改变一下数据的形式。读取的是字符串,现在有了一组数组。这还不够,我们得把数据组织成OpenGL要求的形式。也就是去掉索引,只保留顶点坐标数据。这步操作称为索引。

遍历每个三角形(每个“f”行)的每个顶点(每个 v/vt/vn):

    // For each vertex of each triangle
    for( unsigned int i=0; i

顶点坐标的索引存放到vertexIndices[i]:

unsigned int vertexIndex = vertexIndices[i];

因此坐标是temp_vertices[ vertexIndex-1 ](-1是因为C++的下标从0开始,而OBJ的索引从1开始,还记得吗?):

glm::vec3 vertex = temp_vertices[ vertexIndex-1 ];

这样就有了一个顶点坐标:

out_vertices.push_back(vertex);

UV和法向同理,任务完成!

使用加载的数据

到这一步,几乎什么变化都没发生。这次我们不再声明一个static const GLfloat g_vertex_buffer_data[] = {…},而是创建一个顶点数组(UV和法向同理)。用正确的参数调用loadOBJ:

// Read our .obj file
std::vector vertices;
std::vector uvs;
std::vector normals; // Won't be used at the moment.
bool res = loadOBJ("cube.obj", vertices, uvs, normals);

把数组传给OpenGL:

glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(glm::vec3), &vertices[0], GL_STATIC_DRAW);

结束了!

结果

不好意思,纹理不好看。我不太擅长美工。欢迎您来提供一些好的纹理。

其他模型格式及加载器

这个小巧的加载器应该比较适合初学,不过别在实际中使用它。参考一下实用链接和工具页面,看看有什么能用的。不过请注意,等到第九课才会真正用到这些工具。

OpenGL3.0教程 第六课:键盘和鼠标


OpenGL3.0教程

原文链接:http://www.opengl-tutorial.org/beginners-tutorials/tutorial-6-keyboard-and-mouse/

原译文链接: http://www.opengl-tutorial.org/zh-hans/beginners-tutorials-zh/tutorial-6-keyboard-and-mouse-zh/

欢迎来到第六课!

我们将学习如何通过鼠标和键盘来移动相机,就像在第一人称射击游戏中一样。

接口

这段代码在整个课程中多次被使用,因此把它单独放在一个文件中:common/controls.cpp,然后在common/controls.hpp中声明函数接口,这样tutorial06.cpp就能使用它们了。

和前节课比,tutorial06.cpp里的代码变动很小。主要的变化是:每一帧都计算MVP(投影视图矩阵)矩阵,而不像之前那样只算一次。现在把这段代码加到主循环中:

do{

    // ...

    // Compute the MVP matrix from keyboard and mouse input
    computeMatricesFromInputs();
    glm::mat4 ProjectionMatrix = getProjectionMatrix();
    glm::mat4 ViewMatrix = getViewMatrix();
    glm::mat4 ModelMatrix = glm::mat4(1.0);
    glm::mat4 MVP = ProjectionMatrix * ViewMatrix * ModelMatrix;

    // ...
}

这段代码需要3个新函数:

  • computeMatricesFromInputs()读键盘和鼠标操作,然后计算投影视图矩阵。这就是奇妙所在。
  • getProjectionMatrix()返回计算好的投影矩阵。
  • getViewMatrix()返回计算好的视图矩阵。

这只是一种实现方式,当然,如果你不喜欢这些函数,勇敢地去改写它们。

来看看controls.cpp在做什么。

实际代码

我们需要几个变量。

// position
glm::vec3 position = glm::vec3( 0, 0, 5 );
// horizontal angle : toward -Z
float horizontalAngle = 3.14f;
// vertical angle : 0, look at the horizon
float verticalAngle = 0.0f;
// Initial Field of View
float initialFoV = 45.0f;

float speed = 3.0f; // 3 units / second
float mouseSpeed = 0.005f;

FoV is the level of zoom. 80° = very wide angle, huge deformations. 60° – 45° : standard. 20° : big zoom.

首先根据输入,重新计算位置,水平角,竖直角和视场角(FoV);再由它们算出视图和投影矩阵。

方向

读取鼠标位置是容易的:

// Get mouse position
int xpos, ypos;
glfwGetMousePos(&xpos, &ypos);

我们需要把光标放到屏幕中心,否则它将很快移到屏幕外,导致无法响应。

// Reset mouse position for next frame
glfwSetMousePos(1024/2, 768/2);

注意:这段代码假设窗口大小是1024*768,这不是必须的。你可以用glfwGetWindowSize来设定窗口大小。

计算观察角度:

// Compute new orientation
horizontalAngle += mouseSpeed * deltaTime * float(1024/2 - xpos );
verticalAngle   += mouseSpeed * deltaTime * float( 768/2 - ypos );

从右往左阅读这几行代码:

  • 1024/2 – xpos表示鼠标离窗口中心点的距离。这个值越大,转动角越大。
  • float(…)是浮点数转换,使乘法顺利进行
  • mouseSpeed用来加速或减慢旋转,可以随你调整或让用户选择。
  • += : 如果你没移动鼠标,1024/2-xpos的值为零,horizontalAngle+=0不改变horizontalAngle的值。如果你用的是”=”,每帧视角都被强制转回到原始方向,这就不好了。

现在,在世界坐标系下计算一个向量,代表视线方向。

// Direction : Spherical coordinates to Cartesian coordinates conversion
glm::vec3 direction(
    cos(verticalAngle) * sin(horizontalAngle),
    sin(verticalAngle),
    cos(verticalAngle) * cos(horizontalAngle)
);

这是一种标准计算,如果你不了解余弦和正弦,下面有一个简短的解释:

上面的公式,只是上图在三维空间下的推广。

我们想算出相机的『上方向』。『上方向』不一定是Y轴正方向:你俯视时,『上方向』实际上是水平的。这里有一个例子,位置相同,视点相同的相机,却有不同的『上方向』。

本例中,唯一不变的是,『相机的右边』这个方向始终取水平方向。你可以试试:保持手臂水平伸直,向正上方看、向下看;向这之间的任何方向看(译注:『看』立刻产生视线方向)。现在定义『右方向』向量:因为是水平的,故Y坐标为零,X和Z值就像上图中的一样,只是角度旋转了90度,或Pi/2弧度。

// Right vector
glm::vec3 right = glm::vec3(
    sin(horizontalAngle - 3.14f/2.0f),
    0,
    cos(horizontalAngle - 3.14f/2.0f)
);

我们有一个『右方向』和一个视线方向,或者说是『前方向』。『上方向』垂直于这两者。一个很有用的数学工具可以让三者的联系变得简单:叉乘。

// Up vector : perpendicular to both direction and right
glm::vec3 up = glm::cross( right, direction );

叉乘是在做什么呢?很简单,回忆第三课讲到的右手定则。第一个向量是大拇指;第二个是食指;叉乘的结果就是中指。十分方便。

位置

代码十分直观。顺便说下,我用上/下/右/左键而不用wsad;是因为我的azerty键盘中,美式键盘的awsd键位处实际上是zqsd。qwerZ键盘其实又不一样了,更别提韩国键盘了。我甚至不知道韩国人民用的键盘是什么布局,但我猜想肯定很不一样。

// Move forward
if (glfwGetKey( GLFW_KEY_UP ) == GLFW_PRESS){
    position += direction * deltaTime * speed;
}
// Move backward
if (glfwGetKey( GLFW_KEY_DOWN ) == GLFW_PRESS){
    position -= direction * deltaTime * speed;
}
// Strafe right
if (glfwGetKey( GLFW_KEY_RIGHT ) == GLFW_PRESS){
    position += right * deltaTime * speed;
}
// Strafe left
if (glfwGetKey( GLFW_KEY_LEFT ) == GLFW_PRESS){
    position -= right * deltaTime * speed;
}

这里唯一特别的是deltaTime。你不会希望每帧偏移1单元的,原因很简单:

  • 如果你有一台快电脑,每秒能跑60帧,你每秒移动60*speed个单位。
  • 如果你有一台慢电脑,每秒能跑20帧,你每秒移动20*speed个单位。

电脑性能不能成为速度不稳的借口;你需要通过“前一帧到现在的时间”或“时间间隔(deltaTime)”来控制移动步长。

  • 如果你有一台快电脑,每秒能跑60帧,你每帧移动1/60speed个单位,每秒移动1speed个单位。
  • 如果你有一台慢电脑,每秒能跑20帧,你每帧移动1/20speed个单位,每秒移动1speed个单位。

这就好多了。deltaTime很容易算:

double currentTime = glfwGetTime();
float deltaTime = float(currentTime - lastTime);

视场角

为了好玩,我们可以把视场角绑定到鼠标滚轮,作为简陋的缩放功能:

float FoV = initialFoV - 5 * glfwGetMouseWheel();

计算矩阵

计算矩阵已经很直观了。使用和前面几乎一样的函数,仅参数不同。

// Projection matrix : 45° Field of View, 4:3 ratio, display range : 0.1 unit <-> 100 units
ProjectionMatrix = glm::perspective(FoV, 4.0f / 3.0f, 0.1f, 100.0f);
// Camera matrix
ViewMatrix       = glm::lookAt(
    position,           // Camera is here
    position+direction, // and looks here : at the same position, plus "direction"
    up                  // Head is up (set to 0,-1,0 to look upside-down)
);

结果

隐藏面消除

现在可以自由移动鼠标,你会注意到:如果鼠标移动到立方体里面,多边形仍然会被显示。这看起来理所当然,实则可以优化。事实上,在常见应用中,你从来不会处于立方体内。

有一个思路是让GPU检查相机在三角形的后面还是前面。如果在前面,显示该三角形;如果相机在三角形后面,且不在网格(网格必须是封闭的)内部,那么必有其他三角形在相机前面,故不显示该三角形。没有人会注意到什么,除了一切都会变快:三角形平均少了两倍!

更妙的是,检查起来还很简单:GPU计算三角形的法向(用叉乘,记得吧?),然后检查这个法向是否朝向相机。

不幸的是这样做有代价:三角形的方向是隐式的。这意味着如果你在缓冲区中交换两个顶点,可能会产生洞。但一般来说,它值得做一点额外工作。一般你只要在三维建模软件中点击“反转法向”(实际是交换两个顶点,从而反转法向),一切就正常了。

开启隐藏面消除是很轻松的:

// Cull triangles which normal is not towards the camera
glEnable(GL_CULL_FACE);

练习

  • 限制verticalAngle,使之不能颠倒方向
  • 创建一个相机,使它绕着物体旋转 ( position = ObjectCenter + ( radius * cos(time), height, radius * sin(time) ) );然后将半径/高度/时间的变化绑定到键盘/鼠标上,诸如此类。
  • 玩得开心!
?>