摘要:通过光照贴图(漫反射贴图)绘制为静态水面和水池模型添加纹理和光照,通过正弦波叠加的物理模型实现动态水面震动的效果!
水池部分主要借鉴learnopengl当中的model_loading与lighting_maps两个程序
1-1.首先实现obj**模型加载**,这主要是利用assimp库封装好的加载函数来实现的,该部分仿照model_loading而实现,相应的关键部分代码所示:
//加载pool.obj水池模型
Model ourModel("pool.obj");
...
glm::mat4 model;
// 转换它(模型坐标)使得其(模型)可以同时(在程序初始运行时)被我们观察到正面和顶部
model = glm::translate(model, glm::vec3(0.0f, -1.75f, -2.0f));
// 缩小模型规模使其在场景中显得更加真实
model = glm::scale(model, glm::vec3(0.4f, 0.4f, 0.4f));
glUniformMatrix4fv(glGetUniformLocation(lightingShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
//绘制已加载的模型
ourModel.Draw(lightingShader);
1-2.接下来调整背景色(即池子与水面之外的部分)为浅灰色:
glClearColor(0.2f, 0.2f, 0.2f, 1.0f); //将背景设置为浅灰色
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
1-3.接下来仿照lighting_maps中的代码(需要注意的是lighting_maps程序是同时添加了漫反射贴图与镜面贴图的,本程序为了简单起见,只使用了漫反射贴图),为水池添加一个漫反射光照贴图,关键部分代码如下所示:
// 水池与水面模型着色器,共用着色器可以有效减少代码冗余
Shader lightingShader("lighting_maps.vs", "lighting_maps.frag");
// 加载贴图
GLuint diffuseMap, diffuseMap2;
glGenTextures(1, &diffuseMap);
glGenTextures(1, &diffuseMap2);
int width, height;
unsigned char* image, *image2;
// 漫反射贴图,其实就相当于给我们的水池外边贴上一幅图片,使其看起来像是“木质”的
//水池贴图
image = SOIL_load_image("pool_map.png", &width, &height, 0, SOIL_LOAD_RGB);
glBindTexture(GL_TEXTURE_2D, diffuseMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);
glGenerateMipmap(GL_TEXTURE_2D);
SOIL_free_image_data(image);
//纹理过滤函数,也即是将图象从纹理空间映射到帧缓冲空间
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST_MIPMAP_NEAREST);
// 将纹理传入着色器中
lightingShader.Use();
glUniform1i(glGetUniformLocation(lightingShader.Program, "material.diffuse"), 0);
...
//把相应的摄像机位置坐标传给片段着色器,也即是使用使用摄像机对象的位置坐标代替观察者的位置
lightingShader.Use();
GLint lightPosLoc = glGetUniformLocation(lightingShader.Program, "light.position");
GLint viewPosLoc = glGetUniformLocation(lightingShader.Program, "viewPos");
glUniform3f(lightPosLoc, lightPos.x, lightPos.y, lightPos.z);
glUniform3f(viewPosLoc, camera.Position.x, camera.Position.y, camera.Position.z);
// 设置光线属性
glUniform3f(glGetUniformLocation(lightingShader.Program, "light.ambient"), 0.2f, 0.2f, 0.2f);
glUniform3f(glGetUniformLocation(lightingShader.Program, "light.diffuse"), 0.5f, 0.5f, 0.5f);
glUniform3f(glGetUniformLocation(lightingShader.Program, "light.specular"), 1.0f, 1.0f, 1.0f);
// 材质属性
glUniform1f(glGetUniformLocation(lightingShader.Program, "material.shininess"), 32.0f);
// 摄像机转换矩阵
glm::mat4 view;
view = camera.GetViewMatrix();
glm::mat4 projection = glm::perspective(camera.Zoom, (GLfloat)WIDTH / (GLfloat)HEIGHT, 0.1f, 100.0f);
// 得到统一位置
GLint modelLoc = glGetUniformLocation(lightingShader.Program, "model");
GLint viewLoc = glGetUniformLocation(lightingShader.Program, "view");
GLint projLoc = glGetUniformLocation(lightingShader.Program, "projection");
// 将矩阵转递给着色器
glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(projection));
// 混合环境贴图到纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap);
水面部分可以看作是由两部分(水面纹理贴图和物理模型)组成的:
2-1.水面纹理贴图
该部分的实现和水池的漫反射贴图一样都是采用了漫反射贴图的方式,因而代码部分就可以重用,事实上我们只需要在池子的漫反射贴图实现代码上添加以下两部分就可以了:
//水面贴图
image2 = SOIL_load_image("water.png", &width, &height, 0, SOIL_LOAD_RGB);
glBindTexture(GL_TEXTURE_2D, diffuseMap2);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image2);
glGenerateMipmap(GL_TEXTURE_2D);
SOIL_free_image_data(image2);
...
//激活并使用水面图片纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap2);
lightingShader.Use();
2-2.波的物理模型实现
基本思路可以分为两步:
1.根据水池顶部的四个角点构造出一个正方形10*10格网;
2.接下来由20个正弦波叠加出格网上每一个顶点的Y值,并且让他根据我们的程序运行时间(每0.15s)改变一次,从而实现“动态”效果。
首先需要说明一点,我们使用的这个pool.obj模型的坐标轴与数学上常规的三维空间坐标轴不同,Y轴是在竖直方向,Z轴是在水平方向(参见下图中右下角),因而基本思路中才说动态处理到的结果是展示在Y轴而非常说的Z轴上的:
基本思路步骤1的实现:
要实现这一部分,其实就相当于一开始学的用四个顶点,两组索引绘制出实际上是两个三角形看起来是一个矩形的hello_triangle程序一样,关键点在于构造出顶点数组与索引数组。
顶点数组的基本原理是我们从左上角的那个池子角点(-3,1.5,3)开始,先从左到右、再从上到下的遍历一遍我们的所有格网顶点,直至到达右下角(3,1.5,-3)为止。每次遍历依次添加进x值、y值(任意的,因为我们要实现动态水面,最后的y值是动态计算分配的,该步骤中添加什么都无所谓)
代码实现如下所示:
//pool.obj中的坐标轴与数学上的三维空间坐标轴不同,Y轴是在竖直方向,Z轴是在水平方向,为了与模型把持一致,我们以下的顶点数组中采用这种坐标系
//坐标范围是X轴(-3,3),Z轴(-3,3)
float valueX = -3;
float valueZ = 3; //以上两个参数是为了顶点数组从水池左上角开始计算水面顶点数组,这样比较符合日常直觉
int index = 0;
float number =6.0 / (n - 1); //得到单行/单列的格网数
//顶点数组计算 注意valueX与valueZ的方向与二维笛卡尔坐标的X和Y方向一致
for (int i = 0; i < n*n; i++) //计算的顺序是先保持Z值不变从左到右,然后再移动到下一行进行重复(即优先从左到右,再从上到下)
{
vertices[index++] = valueX;
vertices[index++] = 0; //该部分的值后续是要动态计算的,因而此处赋值多少都可以
vertices[index++] = valueZ;
valueX += number; //移动到下一列
if ((i+1)%n==0) //代表一行已经计算完毕,故而要将valueX(行首的X值)恢复到3,而对Z进行一次减法,使之移动到下一行
{
valueX = -3;
valueZ -= number;
}
}
接下来看索引数组的实现:
为了尽可能的不重复、不遗漏的通过我们的顶点构造出我们的索引数组,我是按如下方式思考的:
下图中左侧是索引的基本方式(即每个正方形块从右上到左下分为两个三角形索引进行组织),右侧是采用伪代码的方式组织起来的索引(包括如何在循环时跳过每行最后一个顶点与下一行第一个顶点之间的不应该存在但按循环会出现的索引,即if当中的那个条件):
代码实现如下所示:
index = 0;
//顶点索引计算,详细参见索引图
for (int i = 0; i < n*(n-1)+1; i++) {
if ((i + 1) % n != 0) //避免每一行的最后一个顶点与下一行第一个顶点之间产生索引索引
{
//三角形A
indices[index++] = i;
indices[index++] = i + 1;
indices[index++] = i + n;
//三角形B
indices[index++] = i + 1;
indices[index++] = i + n;
indices[index++] = i + n + 1;
}
}
最后我们把正方形格网实现的顶点数组和索引数组都封装在如下所示的函数当中:
void VertexIndex(float *vertices, int *indices, int n) //水面顶点数组及索引计算
接下来看基本思路步骤2的实现:
首先先建立一个正弦波叠加的物理模型,该部分我主要参考了知乎GPUGems 基于物理模型的水面模拟学习笔记(一)的专栏,每一个单独的波如下构造:
注: Di×(x,y) 代表的是二范数,也就是每个顶点对应的横纵坐标的根号下平方和!
累加的波如下计算(其实就相当于把刚才的所有单波都加起来):
最后我们把这个累加的值循环赋给每一个顶点的Y分量即可!
该部分封装好的代码如下所示:
void WaterSin(float *vertices, int n) //水面正弦波数组的计算
{
for (int i = 0; i < n*n; i++) {
float d = sqrt((vertices[i * 3] - 3)*(vertices[i * 3] - 3) + (vertices[i * 3 + 2] - 3)*(vertices[i * 3 + 2] - 3));//计算X与Z的二范数
//注意,由于1.5是真实的最高坐标,因而我们最后要让正弦波叠加后的最大值比1.5稍低一些从而使得水面看起来不会“溢出去”
const int WaveNumber = 20;
float wave[WaveNumber]; //将WaveNumber个波都计算到wave数组中
float sum = 0;
for (int j = 0; j < WaveNumber; j++) {
wave[j]= 0.007*sin(d*2/WaveNumber*3.14 + time*0.2*3.14);
sum += wave[j]; //sum即为物理模型下的最终水波
}
vertices[i * 3 + 1] = sum+1.15;
}
}
最后调用其完成绘制即可
注:VAO、VBO、EBO与相应使用可以参见learnopengl中的hello_triangle程序,此处略去!
glUniformMatrix4fv(glGetUniformLocation(lightingShader.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 512, GL_UNSIGNED_INT, 0);
当然为了实现我们的动态效果,就要将绘制部分写在一个循环中,从而控制水面每0.15s可以改变一次Y值,从而实现动态效果!
const int n = 10; //水面区域日后将会被划分为n行n列,以方便进行动态渲染
int time = glfwGetTime(); //控制水面正弦波的初始波动位置的常量
float vertices[512]; //水面顶点数组
int indices[512]; //水面索引数组
…
//在动态绘制之前调用函数计算顶点数组值,提高程序效率
VertexIndex(vertices, indices, n);
…
while (deltaTime > 0.15) //控制每0.15s让水面“动一次”
{
…
//以下为水面部分
WaterSin(vertices, n);
time++; //time是作为全局变量直接传递到WaterSin函数当中的
…
glfwSwapBuffers(window); //向屏幕绘制一次,即交换一次缓冲区
deltaTime = 0.0; //重设计时器
}
最后为了让我们的水显得“清澈”一些,还可以在片元着色器中修改alpha值为0.8左右,使得水池的底部可以为我们所见!
#version 330 core
...
void main()
{
...
color = vec4(ambient + diffuse + specular, 0.8f);
}