OpenGL 核心技术之立方体贴图
笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家。特邀编辑,畅销书作者,国家专利发明人;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术具体解释》电子工业出版社等。
CSDN视频网址:http://edu.csdn.net/lecturer/144
在这里介绍立方体贴图主要是告诉读者,利用立方体贴图原理。我们能够做非常多事情:比方天空盒,环境映射中的反射和折射效果等等。当然环境映射也能够使用一张纹理贴图实现。这个会在博文的最后给读者介绍,以下開始介绍立方体贴图实现原理。
我们在游戏开发中通常的做法是将2D纹理映射到物体的一个面上,本篇博文介绍的是将多个纹理组合起来映射到一个单一纹理,这就称为立方体贴图。在介绍立方体贴图前。先解释一下纹理採样,假设我们有一个单位立方体。有个以原点为起点的方向向量在它的中心。
从立方体贴图上使用橘黄色向量採样一个纹理值看起来下图:
注意,方向向量的大小无关紧要。一旦提供了方向,OpenGL就会获取方向向量碰触到立方体表面上的对应的纹理像素。这样就返回了正确的纹理採样值。
方向向量触碰到立方体表面的一点也就是立方体贴图的纹理位置。这意味着仅仅要立方体的中心位于原点上。我们就能够使用立方体的位置向量来对立方体贴图进行採样。然后我们就能够获取全部顶点的纹理坐标。就和立方体上的顶点位置一样。所获得的结果是一个纹理坐标,通过这个纹理坐标就能获取到立方体贴图上正确的纹理。
以下開始介绍创建立方体贴图,立方体贴图和其它纹理一样。所以要创建一个立方体贴图,在进行不论什么纹理操作之前,须要生成一个纹理。激活对应纹理单元然后绑定到合适的纹理目标上。这次要绑定到 GL_TEXTURE_CUBE_MAP
纹理类型:
GLuint textureID; glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
由于立方体贴图包括6个纹理,立方体的每一个面一个纹理,我们必须调用glTexImage2D
函数6次,函数的參数和前面教程讲的类似。然而这次我们必须把纹理目标(target)參数设置为立方体贴图特定的面。这是告诉OpenGL我们创建的纹理是对应立方体哪个面的。
因此我们便须要为立方体贴图的每一个面调用一次 glTexImage2D
。
由于立方体贴图有6个面,OpenGL就提供了6个不同的纹理目标,来应对立方体贴图的各个面。
纹理目标(Texture target) | 方位 |
---|---|
GL_TEXTURE_CUBE_MAP_POSITIVE_X | 右 |
GL_TEXTURE_CUBE_MAP_NEGATIVE_X | 左 |
GL_TEXTURE_CUBE_MAP_POSITIVE_Y | 上 |
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y | 下 |
GL_TEXTURE_CUBE_MAP_POSITIVE_Z | 后 |
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z | 前 |
GL_TEXTURE_CUBE_MAP_POSITIVE_X
为起始来对它们进行遍历,每次迭代枚举值加 1
,这样循环全部的纹理目标效率较高:int width,height; unsigned char* image; for(GLuint i = 0; i < textures_faces.size(); i++) { image = SOIL_load_image(textures_faces[i], &width, &height, 0, SOIL_LOAD_RGB); glTexImage2D( GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image ); }
这儿我们有个vector叫textures_faces
。它包括立方体贴图所各个纹理的文件路径,而且以上表所列的顺序排列。它将为每一个当前绑定的cubemp的每一个面生成一个纹理。
由于立方体贴图和其它纹理没什么不同,我们也要定义它的围绕方式和过滤方式:
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
简单的解释一下參数GL_TEXTURE_WRAP_R。它的含义仅仅是简单的设置了纹理的R坐标。R坐标对应于纹理的第三个维度(就像位置的z一样)。
我们把放置方式设置为 GL_CLAMP_TO_EDGE
,由于纹理坐标在两个面之间,所以可能并不能触及哪个面(由于硬件限制),因此使用 GL_CLAMP_TO_EDGE
后OpenGL会返回它们的边界的值。虽然我们可能在两个两个面中间进行的採样。
在绘制物体之前。将使用立方体贴图,而在渲染前我们要激活对应的纹理单元并绑定到立方体贴图上。这和普通的2D纹理没什么差别。
在片段着色器中。我们也必须使用一个不同的採样器——samplerCube,用它来从texture
函数中採样。可是这次使用的是一个vec3
方向向量,代替vec2
。以下是一个片段着色器使用了立方体贴图的样例:
in vec3 textureDir; // 用一个三维方向向量来表示立方体贴图纹理的坐标 uniform samplerCube cubemap; // 立方体贴图纹理採样器 void main() { color = texture(cubemap, textureDir); }立方体贴图的技术实现了后,我们利用该技术实现天空盒:
天空盒是一个立方体,它由六个面组成,每一个面须要一个贴图,网上有非常多这种天空盒的资源。当然美术也能够制作,这些天空盒通常有以下的样式:
假设你把这6个面折叠到一个立方体中,你机会获得模拟了一个巨大的风景的立方体。原理清楚了。接下来使用程序创建天空盒:
由于天空盒实际上就是一个立方体贴图,载入天空盒和之前我们载入立方体贴图的没什么大的不同。为了载入天空盒我们将使用以下的函数。它接收一个包括6个纹理文件路径的vector:
GLuint loadCubemap(vector<const GLchar*> faces) { GLuint textureID; glGenTextures(1, &textureID); glActiveTexture(GL_TEXTURE0); int width,height; unsigned char* image; glBindTexture(GL_TEXTURE_CUBE_MAP, textureID); for(GLuint i = 0; i < faces.size(); i++) { image = SOIL_load_image(faces[i], &width, &height, 0, SOIL_LOAD_RGB); glTexImage2D( GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image ); } glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); glBindTexture(GL_TEXTURE_CUBE_MAP, 0); return textureID; }在我们调用这个函数之前,我们将把合适的纹理路径载入到一个vector之中,顺序还是依照立方体贴图枚举的特定顺序:
vector<const GLchar*> faces; faces.push_back("right.jpg"); faces.push_back("left.jpg"); faces.push_back("top.jpg"); faces.push_back("bottom.jpg"); faces.push_back("back.jpg"); faces.push_back("front.jpg"); GLuint cubemapTexture = loadCubemap(faces);
由于天空盒绘制在了一个立方体上。我们还须要还有一个VAO、VBO以及一组全新的顶点,将天空盒显示出来。
立方体贴图用于给3D立方体帖上纹理,能够用立方体的位置作为纹理坐标进行採样。当一个立方体的中心位于原点(0,0。0)的时候。它的每一个位置向量也就是以原点为起点的方向向量。
这个方向向量就是我们要得到的立方体某个位置的对应纹理值。
出于这个理由,我们仅仅须要提供位置向量。而无需纹理坐标。为了渲染天空盒,我们须要一组新着色器,它们不会太复杂。由于我们仅仅有一个顶点属性。顶点着色器非常easy:
#version 330 core layout (location = 0) in vec3 position; out vec3 TexCoords; uniform mat4 projection; uniform mat4 view; void main() { gl_Position = projection * view * vec4(position, 1.0); TexCoords = position; }注意。顶点着色器有意思的地方在于我们把输入的位置向量作为输出给片段着色器的纹理坐标。
片段着色器就会把它们作为输入去採样samplerCube:
#version 330 core in vec3 TexCoords; out vec4 color; uniform samplerCube skybox; void main() { color = texture(skybox, TexCoords); }
片段着色器比較明了。我们把顶点属性中的位置向量作为纹理的方向向量,使用它们从立方体贴图採样纹理值。渲染天空盒如今非常easy,我们有了一个立方体贴图纹理。我们简单绑定立方体贴图纹理。天空盒就自己主动地用天空盒的立方体贴图填充了。为了绘制天空盒,我们将把它作为场景中第一个绘制的物体而且关闭深度写入。
这样天空盒才干成为全部其它物体的背景来绘制出来。
glDepthMask(GL_FALSE); skyboxShader.Use(); // ... Set view and projection matrix glBindVertexArray(skyboxVAO); glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); glDepthMask(GL_TRUE); // ... Draw rest of the scene我们希望天空盒以玩家为中心。这样不管玩家移动了多远。天空盒都不会变近,这样就产生一种四周的环境真的非常大的印象。当前的视图矩阵对全部天空盒的位置进行了转转缩放和平移变换。所以玩家移动,立方体贴图也会跟着移动。我们打算移除视图矩阵的平移部分,这样移动就影响不到天空盒的位置向量了。在基础光照教程里我们提到过我们能够仅仅用4X4矩阵的3×3部分去除平移。我们能够简单地将矩阵转为33矩阵再转回来。就能达到目标。
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));这会移除全部平移。但保留全部旋转。因此用户仍然能够向四面八方看。由于有了天空盒。场景即可变得巨大了。
假设你加入些物体然后自由在当中游荡一会儿你会发现场景的真实度有了极大提升。最后的效果看起来像这样:
实现上述效果的核心代码例如以下所看到的:
// Clear buffers glClearColor(0.1f, 0.1f, 0.1f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Draw skybox first glDepthMask(GL_FALSE);// Remember to turn depth writing off skyboxShader.Use(); glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix())); // Remove any translation component of the view matrix glm::mat4 projection = glm::perspective(camera.Zoom, (float)screenWidth/(float)screenHeight, 0.1f, 100.0f); glUniformMatrix4fv(glGetUniformLocation(skyboxShader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(glGetUniformLocation(skyboxShader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); // skybox cube glBindVertexArray(skyboxVAO); glActiveTexture(GL_TEXTURE0); glUniform1i(glGetUniformLocation(shader.Program, "skybox"), 0); glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); glDepthMask(GL_TRUE); // Then draw scene as normal shader.Use(); glm::mat4 model; view = camera.GetViewMatrix(); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); // Cubes glBindVertexArray(cubeVAO); glActiveTexture(GL_TEXTURE0); glUniform1i(glGetUniformLocation(shader.Program, "texture_diffuse1"), 0); glBindTexture(GL_TEXTURE_2D, cubeTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); // Swap the buffers glfwSwapBuffers(window);
以上仅仅是生成了一个天空盒。还没有去考虑效率问题,接下来我们就分析一下怎样利用深度測试去优化,如今我们在渲染场景中的其它物体之前渲染了天空盒。这么做没错。可是不怎么高效。假设我们先渲染了天空盒,那么我们就是在为每一个屏幕上的像素执行片段着色器,即使天空盒仅仅有部分在显示着。fragment能够使用前置深度測试(early depth testing)简单地被丢弃,这样就节省了我们宝贵的带宽。
所以最后渲染天空盒就能够给我们带来轻微的性能提升。
採用这种方式,深度缓冲被全部物体的深度值全然填充,所以我们仅仅须要渲染通过前置深度測试的那部分天空的片段即可了,而且能显著降低片段着色器的调用。问题是天空盒是个立方体,极有可能会渲染失败。由于极有可能通只是深度測试。简单地不用深度測试渲染它也不是解决方式,这是由于天空盒会在之后覆盖全部的场景中其它物体。我们须要耍个花招让深度缓冲相信天空盒的深度缓冲有着最大深度值1.0。如此仅仅要有个物体存在深度測试就会失败,看似物体就在它前面了。
透视除法(perspective division)是在顶点着色器执行之后执行的。把gl_Position
的xyz坐标除以w元素。我们从深度測试教程了解到除法结果的z元素等于顶点的深度值。利用这个信息。我们能够把输出位置的z元素设置为它的w元素,这样就会导致z元素等于1.0了,由于,当透视除法应用后。它的z元素转换为w/w = 1.0:
void main() { vec4 pos = projection * view * vec4(position, 1.0); gl_Position = pos.xyww; TexCoords = position; }
终于。标准化设备坐标就总会有个与1.0相等的z值了。1.0就是深度值的最大值。仅仅有在没有不论什么物体可见的情况下天空盒才会被渲染(仅仅有通过深度測试才渲染。否则假如有不论什么物体存在,就不会被渲染,仅仅去渲染物体)。
我们必须改变一下深度方程,把它设置为GL_LEQUAL
,原来默认的是GL_LESS
。深度缓冲会为天空盒用1.0这个值填充深度缓冲。所以我们须要保证天空盒是使用小于等于深度缓冲来通过深度測试的,而不是小于。
// Clear buffers glClearColor(0.1f, 0.1f, 0.1f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Draw scene as normal shader.Use(); glm::mat4 model; glm::mat4 view = camera.GetViewMatrix(); glm::mat4 projection = glm::perspective(camera.Zoom, (float)screenWidth/(float)screenHeight, 0.1f, 100.0f); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); // Cubes glBindVertexArray(cubeVAO); glActiveTexture(GL_TEXTURE0); glUniform1i(glGetUniformLocation(shader.Program, "texture_diffuse1"), 0); glBindTexture(GL_TEXTURE_2D, cubeTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); // Draw skybox as last glDepthFunc(GL_LEQUAL); // Change depth function so depth test passes when values are equal to depth buffer's content skyboxShader.Use(); view = glm::mat4(glm::mat3(camera.GetViewMatrix())); // Remove any translation component of the view matrix glUniformMatrix4fv(glGetUniformLocation(skyboxShader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(glGetUniformLocation(skyboxShader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); // skybox cube glBindVertexArray(skyboxVAO); glActiveTexture(GL_TEXTURE0); glUniform1i(glGetUniformLocation(shader.Program, "skybox"), 0); glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); glDepthFunc(GL_LESS); // Set depth function back to default // Swap the buffers glfwSwapBuffers(window);
利用立方体贴图还能够实现环境映射中的反射和折射效果。我们如今有了一个把整个环境映射到为一个单独纹理的对象,我们利用这个信息能做的不仅是天空盒。
使用带有场景环境的立方体贴图,我们还能够让物体有一个反射或折射属性。像这样使用了环境立方体贴图的技术叫做环境贴图技术,当中最重要的两个是反射(reflection)和折射(refraction)。
凡是是一个物体(或物体的某部分)反射(Reflect)他周围的环境的属性,比方物体的颜色多少有些等于它周围的环境,这要基于观察者的角度。
比如一个镜子是一个反射物体:它会基于观察者的角度泛着它周围的环境。
反射的基本思路不难。下图展示了我们怎样计算反射向量,然后使用这个向量去从一个立方体贴图中採样:
我们基于观察方向向量I和物体的法线向量N计算出反射向量R。我们能够使用GLSL的内建函数reflect来计算这个反射向量。
最后向量R作为一个方向向量对立方体贴图进行索引/採样。返回一个环境的颜色值。
最后的效果看起来就像物体反射了天空盒。
由于我们在场景中已经设置了一个天空盒,创建反射就不难了。
我们改变一下箱子使用的那个片段着色器。给箱子一个反射属性:
#version 330 core in vec3 Normal; in vec3 Position; out vec4 color; uniform vec3 cameraPos; uniform samplerCube skybox; void main() { vec3 I = normalize(Position - cameraPos); vec3 R = reflect(I, normalize(Normal)); color = texture(skybox, R); }我们先来计算观察/摄像机方向向量I,然后使用它来计算反射向量R,接着我们用R从天空盒立方体贴图採样。要注意的是,我们有了片段的插值Normal和Position变量,所以我们须要修正顶点着色器适应它。
#version 330 core layout (location = 0) in vec3 position; layout (location = 1) in vec3 normal; out vec3 Normal; out vec3 Position; uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { gl_Position = projection * view * model * vec4(position, 1.0f); Normal = mat3(transpose(inverse(model))) * normal; Position = vec3(model * vec4(position, 1.0f)); }
我们用了法线向量,所以我们打算使用一个法线矩阵(normal matrix)变换它们。Position
输出的向量是一个世界空间位置向量。
顶点着色器输出的Position
用来在片段着色器计算观察方向向量。
由于我们使使用方法线。你还得更新顶点数据,更新属性指针。还要确保设置cameraPos
的uniform。
然后在渲染箱子前我们还得绑定立方体贴图纹理:
glBindVertexArray(cubeVAO); glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0);
编译执行你的代码,你等得到一个镜子一样的箱子。箱子完美地反射了周围的天空盒:
C++程序实现的核心代码例如以下所看到的:
// Clear buffers glClearColor(0.1f, 0.1f, 0.1f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Draw scene as normal shader.Use(); glm::mat4 model; glm::mat4 view = camera.GetViewMatrix(); glm::mat4 projection = glm::perspective(camera.Zoom, (float)screenWidth/(float)screenHeight, 0.1f, 100.0f); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model)); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(glGetUniformLocation(shader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); glUniform3f(glGetUniformLocation(shader.Program, "cameraPos"), camera.Position.x, camera.Position.y, camera.Position.z); // Cubes glBindVertexArray(cubeVAO); glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); // Draw skybox as last glDepthFunc(GL_LEQUAL); // Change depth function so depth test passes when values are equal to depth buffer's content skyboxShader.Use(); glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix())); // Remove any translation component of the view matrix glUniformMatrix4fv(glGetUniformLocation(skyboxShader.Program, "view"), 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(glGetUniformLocation(skyboxShader.Program, "projection"), 1, GL_FALSE, glm::value_ptr(projection)); // skybox cube glBindVertexArray(skyboxVAO); glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture); glDrawArrays(GL_TRIANGLES, 0, 36); glBindVertexArray(0); glDepthFunc(GL_LESS); // Set depth function back to default // Swap the buffers glfwSwapBuffers(window);另外环境映射作用于角色身上的效果例如以下所看到的:
以下介绍反射技术,
环境映射的还有一个形式叫做折射(Refraction),它和反射差点儿相同。折射是光线通过特定材质对光线方向的改变。我们通常看到像水一样的表面。光线并非直接通过的,而是让光线弯曲了一点。它看起来像你把半仅仅手伸进水里的效果。
折射遵守斯涅尔定律,使用环境贴图看起来就像这样:
我们有个观察向量I,一个法线向量N,这次折射向量是R。就像你所看到的那样。观察向量的方向有轻微弯曲。弯曲的向量R随后用来从立方体贴图上採样。
折射能够通过GLSL的内建函数refract来实现。除此之外还须要一个法线向量,一个观察方向和一个两种材质之间的折射指数。
折射指数决定了一个材质上光线扭曲的数量,每一个材质都有自己的折射指数。下表是常见的折射指数:
材质 | 折射指数 |
---|---|
空气 | 1.00 |
水 | 1.33 |
冰 | 1.309 |
玻璃 | 1.52 |
宝石 | 2.42 |
我们使用这些折射指数来计算光线通过两个材质的比率。在我们的样例中,光线/视线从空气进入玻璃(假设我们假设箱子是玻璃做的)所以比率是1.001.52 = 0.658。
我们已经绑定了立方体贴图,提供了定点数据,设置了摄像机位置的uniform。如今仅仅须要改变片段着色器:
void main() { float ratio = 1.00 / 1.52; vec3 I = normalize(Position - cameraPos); vec3 R = refract(I, normalize(Normal), ratio); color = texture(skybox, R); }通过改变折射指数你能够创建出全然不同的视觉效果。编译执行应用,结果也不是太有趣,由于我们仅仅是用了一个普通箱子,这不能显示出折射的效果。看起来像个放大镜。
使用同一个着色器,纳米服模型却能够展示出我们期待的效果:玻璃制物体。
以上都是利用立方体贴图实现的技术,事实上实现环境映射并不一定必须使用立方体贴图,也能够使用一张贴图实现效果,详情查看笔者已经介绍过的案例:
Cocos2d-x 3.x 图形学渲染系列二十二 关于使用一张贴图实现的环境映射效果,效果例如以下所看到的: