源代码:
OpenGL大作业OpenCraft-其他文档类资源-CSDN下载
目录
1. OBJ文件读取
我们先看部分OBJ文件内容。
# material
mtllib Ground.mtl
usemtl palette
# normals
vn -1 0 0
vn 1 0 0
vn 0 0 1
vn 0 0 -1
vn 0 -1 0
vn 0 1 0
# texcoords
vt 0.00195313 0.5
vt 0.00585938 0.5
vt 0.00976563 0.5
vt 0.0136719 0.5
vt 0.0175781 0.5
vt 0.0214844 0.5
vt 0.0253906 0.5
# verts
v -50 0 50
v -50 0 49
v -50 0 48
v -50 0 47
v -50 0 46
v -50 0 45
v -50 0 44
v -50 0 43
v -50 0 42
v -50 0 41
v -50 0 40
# faces
f 101/20/1 2/20/1 1/20/1
f 102/41/1 3/41/1 2/41/1
f 102/20/1 2/20/1 101/20/1
f 103/1/1 4/1/1 3/1/1
f 103/41/1 3/41/1 102/41/1
f 104/2/1 5/2/1 4/2/1
f 104/1/1 4/1/1 103/1/1
f 105/48/1 6/48/1 5/48/1
f 105/2/1 5/2/1 104/2/1
f 106/12/1 7/12/1 6/12/1
f 106/48/1 6/48/1 105/48/1
我们看到在每一行的前面都有字母,v代表的是每个点的位置,vn代表的是每个点的法向量,vt代表的是纹理图片的坐标,f是每个面片的信息,由三组(或者四组)以斜杠分隔的整数表示该面片第 i 个顶点的 位置索引/纹理坐标索引/法向量索引
读取时我么需要将里面的数据一次放在vertex_positions, vertex_normals, vertex_textures, faces, texture_index, noemal_index 当中。
while (std::getline(fin, line))
{
std::istringstream sin(line);
std::string type;
GLfloat _x, _y, _z;
int a0, b0, c0;
int a1, b1, c1;
int a2, b2, c2;
char slash;
// 读取obj文件,记录里面的这些数据
sin >> type;
glm::vec3 tmp_node;
if (type == "v")
{
sin >> tmp_node.x >> tmp_node.y >> tmp_node.z;
vertex_positions.push_back(tmp_node);
if (MinPosition > tmp_node.z)
MinPosition = tmp_node.z;
}
if (type == "vn")
{
sin >> tmp_node.x >> tmp_node.y >> tmp_node.z;
vertex_normals.push_back(tmp_node);
vertex_colors.push_back(tmp_node);
}
if (type == "vt")
{
float x, y, z;
sin >> x >> y >> z;
vertex_textures.push_back(glm::vec2(x, y));
}
if (type == "f")
{
sin >> a0 >> slash >> b0 >> slash >> c0;
sin >> a1 >> slash >> b1 >> slash >> c1;
sin >> a2 >> slash >> b2 >> slash >> c2;
faces.push_back(vec3i(a0 - 1, a1 - 1, a2 - 1));
texture_index.push_back(vec3i(b0 - 1, b1 - 1, b2 - 1));
color_index.push_back(vec3i(c0 - 1, c1 - 1, c2 - 1));
normal_index.push_back(vec3i(c0 - 1, c1 - 1, c2 - 1));
}
// 其中vertex_color和color_index可以用法向量的数值赋值
}
此时已经将所有的点的信息传递给了数组当中。
接下来将根据面片顶点坐标,依次加入GPU points等容器中。
在此之前,先将物体的大小进行了归一化处理,也就是让所有的物体尺寸处于同一大小。这个可以通过setNormalize函数进行控制,我们看看若没有进行归一化是怎样的效果。

可以看到wawa的模型十分大,直接包围了table模型。
所以说归一化的操作是十分有必要的,让两个物体大小相差不大。
归一化代码如下:
if (do_normalize_size)
{
// 记录物体包围盒大小,可以用于大小的归一化
// 先获得包围盒的对角顶点
float max_x = -FLT_MAX;
float max_y = -FLT_MAX;
float max_z = -FLT_MAX;
float min_x = FLT_MAX;
float min_y = FLT_MAX;
float min_z = FLT_MAX;
for (int i = 0; i < vertex_positions.size(); i++)
{
auto &position = vertex_positions[i];
if (position.x > max_x)
max_x = position.x;
if (position.y > max_y)
max_y = position.y;
if (position.z > max_z)
max_z = position.z;
if (position.x < min_x)
min_x = position.x;
if (position.y < min_y)
min_y = position.y;
if (position.z < min_z)
min_z = position.z;
}
up_corner = glm::vec3(max_x, max_y, max_z);
down_corner = glm::vec3(min_x, min_y, min_z);
center = glm::vec3((min_x + max_x) / 2.0, (min_y + max_y) / 2.0, (min_z + max_z) / 2.0);
diagonal_length = length(up_corner - down_corner);
minz = FLT_MAX; //找到最低的点
minx = FLT_MAX;
miny = FLT_MAX;
for (int i = 0; i < vertex_positions.size(); i++)
{
vertex_positions[i] = (vertex_positions[i] - center) / diagonal_length;
if (minz > vertex_positions[i].z)
minz = vertex_positions[i].z;
if (miny > vertex_positions[i].y)
miny = vertex_positions[i].y;
if (minx > vertex_positions[i].x)
minx = vertex_positions[i].x;
}
}
然后将点信息放进GPU存储器当中。
for (int i = 0; i < faces.size(); i++)
{
// 坐标
points.push_back(vertex_positions[faces[i].x]);
points.push_back(vertex_positions[faces[i].y]);
points.push_back(vertex_positions[faces[i].z]);
// 颜色
colors.push_back(vertex_colors[color_index[i].x]);
colors.push_back(vertex_colors[color_index[i].y]);
colors.push_back(vertex_colors[color_index[i].z]);
// 法向量
normals.push_back(vertex_normals[normal_index[i].x]);
normals.push_back(vertex_normals[normal_index[i].y]);
normals.push_back(vertex_normals[normal_index[i].z]);
// 纹理
textures.push_back(vertex_textures[texture_index[i].x]);
textures.push_back(vertex_textures[texture_index[i].y]);
textures.push_back(vertex_textures[texture_index[i].z]);
}
这样子所有的模型存储在GPU当中,准备进行建模。
2. 物体渲染与纹理着色。
在main函数中,我编写了一个addMeshes函数,调用这个函数就可以将物体加入到painter当中,使用着色器进行进一步的渲染。
void addMeshes(glm::vec3 Translation, glm::vec3 Rotation, bool setNormalize, std::string Name,
std::string OBJLocation, std::string TextureLocation, glm::vec3 Scall = glm::vec3(1.0, 1.0, 1.0))
{
std::string vshader, fshader;
// 读取着色器并使用
// for Windows
vshader = "shaders/vshader_win.glsl";
fshader = "shaders/fshader_win.glsl";
TriMesh *TMesh = new TriMesh();
TMesh->setNormalize(true);
TMesh->readObj(OBJLocation);
// 设置物体的旋转位移
TMesh->setRotation(Rotation);
TMesh->setScale(Scall);
TMesh->setTranslation(Translation + glm::vec3(0.0, -(TMesh->miny * Scall.y), 0.0)); //使物体始终处于地面上方
TMesh->setAmbient(glm::vec4(0.2, 0.2, 0.2, 1.0)); // 环境光
TMesh->setDiffuse(glm::vec4(0.7, 0.7, 0.7, 1.0)); // 漫反射
TMesh->setSpecular(glm::vec4(0.2, 0.2, 0.2, 1.0)); // 镜面反射
TMesh->setShininess(1.0); //高光系数
// 加到painter中
painter->addMesh(TMesh, Name, TextureLocation, vshader, fshader); // 指定纹理与着色器
}
该函数当中,只需要输入物体初始状态的位置、角度、比例、物体名称、是否需要将物体归一化和物体文件地址和纹理地址。在函数中,调用TriMesh创建一个物体,设置其位置大小比例,再设置其光反射系数。最后将设置好的物体加入到painter当中,调用painter的addMesh函数,准备开始着色。
void MeshPainter::addMesh(TriMesh *mesh, const std::string &name, const std::string &texture_image,
const std::string &vshader, const std::string &fshader)
{
mesh_names.push_back(name);
meshes.push_back(mesh);
openGLObject object;
bindObjectAndData(mesh, object, texture_image, vshader, fshader);
opengl_objects.push_back(object);
};
在addMesh函数当中,首先将物体存入到meshes数组当中,所有产生的单一物体都会存放在meshes函数当中,以便之后的渲染,接着调用bindObjectAndData函数,将物体调用到着色器当中,准备渲染。
接下来,若要增加物体,直接在main文件当中调用addMeshes函数即可。
最后在display函数中,调用painter的drawMeshes函数便可实现物体在窗口当中显示。
void MeshPainter::drawMeshes(Light *light, Camera *camera)
{
drawMesh(meshes[0], opengl_objects[0], light, camera, meshes[0]->getModelMatrix(), 0);
//地面不需要阴影
for (int i = 1; i < meshes.size(); i++)
{
drawMesh(meshes[i], opengl_objects[i], light, camera, meshes[i]->getModelMatrix());
}
};
void MeshPainter::drawMesh(TriMesh *mesh, openGLObject &object,
Light *light, Camera *camera, glm::mat4 modelMatrix)
{
// 相机矩阵计算
camera->updateCamera();
camera->viewMatrix = camera->getViewMatrix();
camera->projMatrix = camera->getProjectionMatrix(true);
#ifdef __APPLE__ // for MacOS
glBindVertexArrayAPPLE(object.vao);
#else
glBindVertexArray(object.vao);
#endif
glUseProgram(object.program);
// 物体的变换矩阵
// 传递矩阵
glUniformMatrix4fv(object.modelLocation, 1, GL_FALSE, &modelMatrix[0][0]);
glUniformMatrix4fv(object.viewLocation, 1, GL_TRUE, &camera->viewMatrix[0][0]);
glUniformMatrix4fv(object.projectionLocation, 1, GL_TRUE, &camera->projMatrix[0][0]);
// 将着色器 isShadow 设置为0,表示正常绘制的颜色,如果是1着表示阴影
glUniform1i(object.shadowLocation, 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, object.texture); // 该语句必须,否则将只使用同一个纹理进行绘制
// 传递纹理数据 将生成的纹理传给shader
glUniform1i(glGetUniformLocation(object.program, "texture"), 0);
// 将材质和光源数据传递给着色器
bindLightAndMaterial(mesh, object, light, camera);
// 绘制
glDrawArrays(GL_TRIANGLES, 0, mesh->getPoints().size());
#ifdef __APPLE__ // for MacOS
glBindVertexArrayAPPLE(0);
#else
glBindVertexArray(0);
#endif
glUseProgram(0);
}
经过上述操作,便可实现物体在窗口当中显示。
实现纹理的着色是使用glBindTexture函数,只需要将相应的点与纹理图片,即可使物体附着上纹理。
3. 相机变换的实现
相机变换的实现封装在了Camera类当中,此次是为了实现FPS第一人称射击游戏那样子的视角交互。
为了实现该功能,我们可以使用欧拉角从而实现。对于欧拉角来说,重要的三个参数是yaw轴,pitch轴和row轴。yaw轴代表的参数是与y轴垂直方向的角度,pitch轴代表的参数是与x轴垂直方向的角度,raw轴是与z轴垂直的角度。在FPS相机当中我们只关心pitch轴和yaw轴。

我们可以根据公式知道使用欧拉角计算相机的朝向,从而将参数传输到cameraDirection当中。
// 计算欧拉角以确定相机的朝向 cameraDirection表示摄像机的朝向向量
float cameraDirectionX = -cos(glm::radians(pitch)) * sin(glm::radians(yaw));
float cameraDirectionY = sin(glm::radians(pitch));
float cameraDirectionZ = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
这样子就可以通过其知道相机的朝向。
关于相机的移动,我们可以通过更改eye参数进行。对于向前,向后,我们可以根据欧拉角的yaw轴来进行计算。
// 键盘事件处理
// 通过按键改变相机和投影的参数
//通过计算,可以使的一直以相机的位置进行移动
if (key == GLFW_KEY_A && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_A && action == GLFW_REPEAT && mode == 0x0000)
{
eyez += cos((yaw - 90) / 180 * PI) * 0.1;
eyex -= sin((yaw - 90) / 180 * PI) * 0.1;
}
else if (key == GLFW_KEY_D && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_D && action == GLFW_REPEAT && mode == 0x0000)
{
eyez += cos((yaw + 90) / 180 * PI) * 0.1;
eyex -= sin((yaw + 90) / 180 * PI) * 0.1;
}
else if (key == GLFW_KEY_W && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_W && action == GLFW_REPEAT && mode == 0x0000)
{
eyez += cos(yaw / 180 * PI) * 0.1;
eyex -= sin(yaw / 180 * PI) * 0.1;
}
else if (key == GLFW_KEY_S && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_S && action == GLFW_REPEAT && mode == 0x0000)
{
eyez -= cos(yaw / 180 * PI) * 0.1;
eyex += sin(yaw / 180 * PI) * 0.1;
}
按照上述编写,即可实现相机根据yaw轴,始终前进的时候是按照相机的欧拉角方向进行前进。
使用相机矩阵时,先进行相机的参数更新,再是获取相机的视角矩阵,最后获得渲染的矩阵。
// 相机矩阵计算
camera->updateCamera();
camera->viewMatrix = camera->getViewMatrix();
camera->projMatrix = camera->getProjectionMatrix(true);
void Camera::updateCamera()
{
// 设置相机位置和方向
up = glm::vec4(0.0, 1.0, 0.0, 0.0);
// 计算欧拉角以确定相机的朝向 cameraDirection表示摄像机的朝向向量
float cameraDirectionX = -cos(glm::radians(pitch)) * sin(glm::radians(yaw));
float cameraDirectionY = sin(glm::radians(pitch));
float cameraDirectionZ = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
// 确定每时每刻的对应的相机的朝向
cameraDirection = glm::vec4(cameraDirectionX, cameraDirectionY, cameraDirectionZ, 1.0);
eye = glm::vec4(eyex, eyey, eyez, 1.0);
}
对于更改相机视角的欧拉角,是通过下面mouse函数进行,从而对yaw轴和pitch轴进行设置。
//通过对应输入的x和y变量,变动yaw轴和pitch轴的变量
void Camera::mouse(double x, double y)
{
yaw += x;
pitch += y;
const float AEdis = 2.4;
float dis;
if (eyey > AEdis)
dis = 89.5f;
else
dis = 90 - acos(eyey / AEdis) / PI * 180; //此计算是为了让视角一直保持在地面上方
if (pitch > dis)
pitch = dis;
if (pitch < -89.0f)
pitch = -89.0f;
}
4. 光照与阴影的实现
在上述操作当中,我们实际上已经将光照设计好了,首先我们要确定光照的位置。通过Light类来进行确定,直接调用light相关函数进行设置。
// 设置光源位置
light->setTranslation(glm::vec3(0.0, 40.0, 20.0));
light->setAmbient(glm::vec4(1.0, 1.0, 1.0, 1.0)); // 环境光
light->setDiffuse(glm::vec4(1.0, 1.0, 1.0, 1.0)); // 漫反射
light->setSpecular(glm::vec4(1.0, 1.0, 1.0, 1.0)); // 镜面反射
light->setAttenuation(1.0, 0.045, 0.0075); // 衰减系数
然后在drawMesh函数当中调用bindLightAndMaterial函数即可实现。
void MeshPainter::bindLightAndMaterial(TriMesh *mesh, openGLObject &object, Light *light, Camera *camera)
{
// 传递材质、光源等数据给着色器
// 传递相机的位置
glUniform3fv(glGetUniformLocation(object.program, "eye_position"), 1, &camera->eye[0]);
// 传递物体的材质
glm::vec4 meshAmbient = mesh->getAmbient();
glm::vec4 meshDiffuse = mesh->getDiffuse();
glm::vec4 meshSpecular = mesh->getSpecular();
float meshShininess = mesh->getShininess();
glUniform4fv(glGetUniformLocation(object.program, "material.ambient"), 1, &meshAmbient[0]);
glUniform4fv(glGetUniformLocation(object.program, "material.diffuse"), 1, &meshDiffuse[0]);
glUniform4fv(glGetUniformLocation(object.program, "material.specular"), 1, &meshSpecular[0]);
glUniform1f(glGetUniformLocation(object.program, "material.shininess"), meshShininess);
// 传递光源信息
glm::vec4 lightAmbient = light->getAmbient();
glm::vec4 lightDiffuse = light->getDiffuse();
glm::vec4 lightSpecular = light->getSpecular();
glm::vec3 lightPosition = light->getTranslation();
glUniform4fv(glGetUniformLocation(object.program, "light.ambient"), 1, &lightAmbient[0]);
glUniform4fv(glGetUniformLocation(object.program, "light.diffuse"), 1, &lightDiffuse[0]);
glUniform4fv(glGetUniformLocation(object.program, "light.specular"), 1, &lightSpecular[0]);
glUniform3fv(glGetUniformLocation(object.program, "light.position"), 1, &lightPosition[0]);
glUniform1f(glGetUniformLocation(object.program, "light.constant"), light->getConstant());
glUniform1f(glGetUniformLocation(object.program, "light.linear"), light->getLinear());
glUniform1f(glGetUniformLocation(object.program, "light.quadratic"), light->getQuadratic());
}
此时若要实现Phone光照模型,则需要在fshader和vshader当中进行更改。
在fshader当中,我们将以下代码加入到不是阴影时的当中。
void main()
{
if (isShadow == 1) {
fColor = vec4(0.0, 0.0, 0.0, 0.5);
} else {
//计算四个归一化的向量 N,V,L,R(或半角向量H)
vec3 N= normalize(normal);
vec3 V= normalize(eye_position - position);
vec3 L= normalize(light.position - position);
vec3 R= normalize(reflect(-L, N));
//计算环境光分量I_a
vec4 I_a = light.ambient * material.ambient;
// 计算漫反射系数alpha和漫反射分量I_d
float diffuse_dot = 0.0;
diffuse_dot = max(dot(L, N), 0);
vec4 I_d = diffuse_dot * light.diffuse * material.diffuse;
// 计算高光系数beta和镜面反射分量I_s
float specular_dot_pow = 0.0;
specular_dot_pow = pow(max(dot(R, V),0), material.shininess);
vec4 I_s = specular_dot_pow * light.specular * material.specular;
// 合并三个分量的颜色,修正透明度
fColor = texture2D( texture, texCoord );
// 叠加phong模型光照颜色
// 修正得到最后的颜色
// 其中id和is加上衰减系数
float d = distance(light.position,position);
// 使用衰减系数进行模拟
fColor += I_a + 1/(light.constant+light.linear*d+light.quadratic*d*d)*(I_d+I_s);
}
}
首先将四个归一化向量修改好。
// 计算四个归一化的向量 N,V,L,R(或半角向量H)
vec3 N= normalize(normal);
vec3 V= normalize(eye_position - position);
vec3 L= normalize(light.position - position);
vec3 R= normalize(reflect(-L, N));
根据漫反射公式
![]()
将算出漫反射系数alpha,并且算出
(I_d)。
//计算漫反射系数alpha和漫反射分量I_d
float diffuse_dot = 0.0;
diffuse_dot = max(dot(L, N), 0);
vec4 I_d = diffuse_dot * light.diffuse * material.diffuse;
再根据镜面反射公式
![]()
将算出计算高光系数beta和镜面反射分量
(I_s)。
// 计算高光系数beta和镜面反射分量I_s
float specular_dot_pow = 0.0;
specular_dot_pow = pow(max(dot(R, V),0), material.shininess);
vec4 I_s = specular_dot_pow * light.specular * material.specular;
这样子phong光照模型就计算好了。
5. 阴影设计
在drawMesh函数当中,当物体渲染完毕后就对阴影进行渲染,将以下代码加入到drawMesh函数后即可实现。
//接下来是对阴影的设置。
glm::vec4 light_position = light->getLightPosition();
float ly = light_position[1];
glBindVertexArray(object.vao);
glUseProgram(object.program);
modelMatrix = light->getShadowProjectionMatrix() * modelMatrix;
glUniformMatrix4fv(object.modelLocation, 1, GL_FALSE, &modelMatrix[0][0]);
glUniformMatrix4fv(object.viewLocation, 1, GL_TRUE, &camera->viewMatrix[0][0]);
glUniformMatrix4fv(object.projectionLocation, 1, GL_TRUE, &camera->projMatrix[0][0]);
glUniform1i(object.shadowLocation, 1);
glDrawArrays(GL_TRIANGLES, 0, mesh->getPoints().size());
#ifdef __APPLE__ // for MacOS
glBindVertexArrayAPPLE(0);
#else
glBindVertexArray(0);
#endif
glUseProgram(0);
通过调用light的getShadowProjectionMatrix即可。
6. 层级建模
进行层级建模时我们首先要构思模型的各个结构,以下是层级建模的大致框架。

以上是层级建模的大致框架。首先我们需要将一些参数进行绑定。我写了一个Robot的结构体,这样可以方便的查找数据。
// Robot类,用于储存层级建模模型的关键信息
struct Robot
{
// 关节大小
float BODY_HEIGHT = 2.5;
float BODY_WIDTH = 1.5;
float BIG_ARM_HEIGHT = 1.5;
float SMALL_ARM_HEIGHT = 1.0;
float BIG_ARM_WIDTH = 0.8;
float HAND_HEIGHT = 0.6;
float SMALL_ARM_WIDTH = 0.5;
float SWORD_HEIGHT = 2.2;
float HAND_WIDTH = 0.6;
float SWORD_WIDTH = 0.5;
float HEAD_HEIGHT = 1.2;
float HEAD_WIDTH = 1.2;
// 关节角和菜单选项值
enum
{
Body, // 躯干
Head, // 头部
RightBigArm, // 右大臂
RightSmallArm, // 右小臂
LeftBigArm, // 左大臂
LeftSmallArm, // 左小臂
RightHand, // 右手
RightSword, // 右剑
LeftHand, // 左手
LeftSword, // 左剑
};
// 关节角大小
GLfloat theta[10] = {
0.0, // Body
0.0, // Head
0.0, // RightBigArm
-90.0, // RightSmallArm
0.0, // LeftBigArm
-90.0, // LeftSmallArm
0.0, // RightHand
0.0, // RightSword
0.0, // LeftHand
0.0 // LeftSword
};
};
接下来,我是使用栈的方式进行建模,所以构造了一个矩阵栈。
//矩阵栈,用来存放矩阵的栈
class MatrixStack
{
int _index;
int _size;
glm::mat4 *_matrices;
public:
MatrixStack(int numMatrices = 100) : _index(0), _size(numMatrices)
{
_matrices = new glm::mat4[numMatrices];
}
~MatrixStack()
{
delete[] _matrices;
}
void push(const glm::mat4 &m)
{
assert(_index + 1 < _size);
_matrices[_index++] = m;
}
glm::mat4 &pop()
{
assert(_index - 1 >= 0);
_index--;
return _matrices[_index];
}
};
接下来,使用TriMesh数组,对层级建模的每一个物体进行绑定,并且在此定义object准备绑定。
Robot robot;
std::vector<TriMesh *> Man;
openGLObject BodyObject;
openGLObject HeadObject;
openGLObject RightBigArmObject;
openGLObject RightSmallArmObject;
openGLObject LeftBigArmObject;
openGLObject LeftSmallArmObject;
openGLObject RightHandObject;
openGLObject RightSwordObject;
openGLObject LeftHandObject;
openGLObject LeftSwordObject;
在init函数当中,对所有的层级建模物体进行初始化。
//添加层级建模物体
for (int i = 0; i < MANNUM; i++)
{
TriMesh *Mesh = new TriMesh();
Mesh->setTranslation(glm::vec3(0.0, 0.0, 0.0));
Mesh->setRotation(glm::vec3(0.0, 0.0, 0.0));
Mesh->setScale(glm::vec3(1.0, 1.0, 1.0));
Mesh->setNormalize(true);
Mesh->setAmbient(glm::vec4(0.2, 0.2, 0.2, 1.0)); // 环境光
Mesh->setDiffuse(glm::vec4(0.7, 0.7, 0.7, 1.0)); // 漫反射
Mesh->setSpecular(glm::vec4(0.2, 0.2, 0.2, 1.0)); // 镜面反射
Mesh->setShininess(1.0); //高光系数
Man.push_back(Mesh);
}
Man[robot.Body]->readObj("./assets/Man/Body.obj");
Man[robot.LeftBigArm]->readObj("./assets/Man/BigArm.obj");
Man[robot.RightBigArm]->readObj("./assets/Man/BigArm.obj");
Man[robot.LeftSmallArm]->readObj("./assets/Man/SmallArm.obj");
Man[robot.RightSmallArm]->readObj("./assets/Man/SmallArm.obj");
Man[robot.RightHand]->readObj("./assets/Man/hands.obj");
Man[robot.LeftHand]->readObj("./assets/Man/hands.obj");
Man[robot.RightSword]->readObj("./assets/Man/Sword.obj");
Man[robot.LeftSword]->readObj("./assets/Man/Sword.obj");
Man[robot.Head]->readObj("./assets/Man/Head.obj");
painter->bindObjectAndData(Man[robot.Body], BodyObject, "./assets/Man/Body.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.LeftBigArm], LeftBigArmObject, "./assets/Man/BigArm.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.RightBigArm], RightBigArmObject, "./assets/Man/BigArm.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.LeftSmallArm], LeftSmallArmObject, "./assets/Man/SmallArm.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.RightSmallArm], RightSmallArmObject, "./assets/Man/SmallArm.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.RightHand], RightHandObject, "./assets/Man/hands.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.LeftHand], LeftHandObject, "./assets/Man/hands.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.RightSword], RightSwordObject, "./assets/Man/Sword.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.LeftSword], LeftSwordObject, "./assets/Man/Sword.png", vshader, fshader);
painter->bindObjectAndData(Man[robot.Head], HeadObject, "./assets/Man/Head.png", vshader, fshader);
首先是定义Man中的TriMesh,对其进行初始化,然后进行对文件的读取,最后将文件与物体进行一个绑定。
在display函数中,是对物体进行拼接的地方,在此我写了一个drawMulMesh函数,将物体进行拼接。
首先渲染身体,这是机器人的中心部分。
// 躯干(这里我们希望机器人的躯干只绕Y轴旋转,所以只计算了RotateY)
if (ManModeFlag == true)
{
//当跟随模式开启,躯干的位置就是相机的位置,并且若头部和身体相差30度时,身体也跟随一起转动
modelMatrix = glm::translate(modelMatrix, glm::vec3(camera->eyex, 0.0, camera->eyez));
if (robot.theta[robot.Head] - robot.theta[robot.Body] > 30)
robot.theta[robot.Body] = robot.theta[robot.Head] - 30;
if (robot.theta[robot.Head] - robot.theta[robot.Body] < -30)
robot.theta[robot.Body] = robot.theta[robot.Head] + 30;
}
else
modelMatrix = glm::translate(modelMatrix, glm::vec3(0.0, 0.0, 0.0));
modelMatrix = glm::rotate(modelMatrix, glm::radians(robot.theta[robot.Body]), glm::vec3(0.0, 1.0, 0.0));
painter->drawMesh(Man[robot.Body], BodyObject, light, camera, body(modelMatrix));
mstack.push(modelMatrix); // 保存躯干变换矩阵
首先这个modelMatrix代表了这个身体物体的变换矩阵,可以通过这个改变ModelMatrix来改变身体部件。
//以下是层级建模用于控制关节点到物体的距离
glm::mat4 body(glm::mat4 modelMatrix)
{
// 本节点局部变换矩阵
glm::mat4 instance = glm::mat4(1.0);
instance = glm::translate(instance, glm::vec3(0.0, robot.BODY_HEIGHT * 0.5, 0.0));
instance = glm::scale(instance, glm::vec3(robot.BODY_WIDTH, robot.BODY_HEIGHT, robot.BODY_WIDTH));
return modelMatrix * instance;
}
接下来的body函数是在原本modelMatrix的基础上,对身体进行一个变换。然后将物体调用painter的drawMesh进行渲染。
再将本身身体的modelMatrix放入栈中,方便返回。然后对头部进行设计。
// 头部(这里我们希望机器人的头部只绕Y轴旋转,所以只计算了RotateY)
modelMatrix = glm::translate(modelMatrix, glm::vec3(0.0, robot.BODY_HEIGHT, 0.0));
if (ManModeFlag == true)
{
//当启动跟随模式,头部转动一直与相机欧拉角转动一致
robot.theta[robot.Head] = -camera->yaw;
modelMatrix = glm::rotate(modelMatrix, glm::radians(robot.theta[robot.Head] - robot.theta[robot.Body]), glm::vec3(0.0, 1.0, 0.0));
modelMatrix = glm::rotate(modelMatrix, glm::radians(-camera->pitch), glm::vec3(1.0, 0.0, 0.0));
}
else
modelMatrix = glm::rotate(modelMatrix, glm::radians(robot.theta[robot.Head]), glm::vec3(0.0, 1.0, 0.0));
painter->drawMesh(Man[robot.Head], HeadObject, light, camera, head(modelMatrix));
modelMatrix = mstack.pop(); // 恢复躯干变换矩阵
在此是设置头部与身体处的连接位置,也就是若要进行旋转,则在该位置进行旋转,并且通过rorate函数进行旋转。然后再到head函数当中,设置关节节点与物体的位置,使得完美的契合。
glm::mat4 head(glm::mat4 modelMatrix)
{
// 本节点局部变换矩阵
glm::mat4 instance = glm::mat4(1.0);
instance = glm::translate(instance, glm::vec3(0.0, -0.2, 0.0));
instance = glm::scale(instance, glm::vec3(robot.HEAD_WIDTH, robot.HEAD_HEIGHT, robot.HEAD_WIDTH));
return modelMatrix * instance;
}
设置完毕头部后,因为头部下面没有任何的部位了,所以此时将modelMatrix恢复成身体的样式,再进行接下来操作。
接下来的操作与上述大体相同,当某个部位下方没有任何部位后,则将栈中的变换矩阵返回,再进行后续操作。这样子通过栈可以实现层级建模。最终效果如下:

7.添加动画
添加动画,可以根据display一直在运行的特性进行操作。首先定义一个TIME,当每运行一次display时将time进行加1,此时就可以获得一个相对时间的参数,我们可以通过这个相对时间的变换对物体制作动画。
float Fun = cos(TIME / Scale) * 90 - 90;
// =========== 左臂 ===========
mstack.push(modelMatrix); // 保存躯干变换矩阵
// 左大臂
Fun = cos(TIME / Scale) * 90 - 90;
modelMatrix = glm::translate(modelMatrix, glm::vec3(-0.43 * robot.BODY_WIDTH, 1.7, 0.0));
modelMatrix = glm::rotate(modelMatrix, glm::radians(robot.theta[robot.LeftBigArm] + Fun), glm::vec3(0.0, 0.0, 1.0));
painter->drawMesh(Man[robot.LeftBigArm], LeftBigArmObject, light, camera, Big_Arm(modelMatrix));
//左小臂
Fun = cos(TIME / Scale) * 45 - 45;
modelMatrix = glm::translate(modelMatrix, glm::vec3(0.0, -robot.BIG_ARM_HEIGHT * 0.35, 0.0));
modelMatrix = glm::rotate(modelMatrix, glm::radians(robot.theta[robot.LeftSmallArm] - Fun), glm::vec3(1.0, 0.0, 0.0));
painter->drawMesh(Man[robot.LeftSmallArm], LeftSmallArmObject, light, camera, Small_Arm(modelMatrix));
这里我是使用TIME来实现层级建模模型手臂的动画,通过TIME和特定函数改变手臂的转动角度,从而实现手臂的运动。
8. 交互
1.视角移动
视角移动是使用了FPS游戏当中常见的WASD按键进行控制,并且使用SHIFT和CONTROL键控制视角位置的高度。
void Camera::keyboard(int key, int action, int mode)
{
// 键盘事件处理
// 通过按键改变相机和投影的参数
//通过计算,可以使的一直以相机的位置进行移动
if (key == GLFW_KEY_A && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_A && action == GLFW_REPEAT && mode == 0x0000)
{
eyez += cos((yaw - 90) / 180 * PI) * 0.1;
eyex -= sin((yaw - 90) / 180 * PI) * 0.1;
}
else if (key == GLFW_KEY_D && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_D && action == GLFW_REPEAT && mode == 0x0000)
{
eyez += cos((yaw + 90) / 180 * PI) * 0.1;
eyex -= sin((yaw + 90) / 180 * PI) * 0.1;
}
else if (key == GLFW_KEY_W && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_W && action == GLFW_REPEAT && mode == 0x0000)
{
eyez += cos(yaw / 180 * PI) * 0.1;
eyex -= sin(yaw / 180 * PI) * 0.1;
}
else if (key == GLFW_KEY_S && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_S && action == GLFW_REPEAT && mode == 0x0000)
{
eyez -= cos(yaw / 180 * PI) * 0.1;
eyex += sin(yaw / 180 * PI) * 0.1;
}
else if (key == GLFW_KEY_LEFT_SHIFT && action == GLFW_PRESS && mode == GLFW_MOD_SHIFT || key == GLFW_KEY_LEFT_SHIFT && action == GLFW_REPEAT && mode == GLFW_MOD_SHIFT)
{
eyey += 0.1;
}
else if (key == GLFW_KEY_LEFT_CONTROL && action == GLFW_PRESS && mode == GLFW_MOD_CONTROL || key == GLFW_KEY_LEFT_CONTROL && action == GLFW_REPEAT && mode == GLFW_MOD_CONTROL)
{
const float AEdis = 2.5;
if (eyey > (AEdis * sin(pitch * PI / 180)) && eyey > 0)
eyey -= 0.1;
}
else if (key == GLFW_KEY_SPACE && action == GLFW_PRESS && mode == 0x0000 || key == GLFW_KEY_SPACE && action == GLFW_REPEAT && mode == 0x0000)
{
radius = 4.0;
rotateAngle = 0.0;
upAngle = 0.0;
fov = 45.0;
aspect = 1.0;
scale = 1.5;
eyex = radius * cos(upAngle * M_PI / 180.0) * sin(rotateAngle * M_PI / 180.0);
eyez = radius * cos(upAngle * M_PI / 180.0) * cos(rotateAngle * M_PI / 180.0);
eyey = radius * sin(upAngle * M_PI / 180.0);
eyey += 1;
yaw = 179;
pitch = -15;
}
}
跟据公式可以计算出来根据yaw轴角度使得按下W键使得总是以视角前进方向,同理得到ASD键。并且在进行下降时,根据计算若pitch轴处于一定角度时,不能再进行下降,避免视角处于地面以下。

未加角度限制

增加角度限制
关于其余欧拉角的设置时使用的鼠标交互。通过查阅文档可知可以通过glfwSetCursorPosCallback函数隐藏鼠标并且当鼠标移动时获取鼠标的相对位置。
//隐藏鼠标并且随着鼠标移动改变相机视角
float lastX = 400, lastY = 300;
void mouse_callback(GLFWwindow *window, double xpos, double ypos)
{
//获取上一帧与下一帧鼠标位置差
float xoffset = xpos - lastX;
float yoffset = lastY - ypos;
lastX = xpos;
lastY = ypos;
//鼠标灵敏度
float sensitivity = 0.05f;
xoffset *= sensitivity;
yoffset *= sensitivity;
camera->mouse(xoffset, yoffset);
}
在mouse_callback当中,使用上一帧和此帧的坐标差,来进行输入,这样子就可以做到FPS这样的视角了,同时加上sensitivity,代表的是灵敏度。
2. 鼠标左右键添加删除物体
使用mouse_button_callback函数对鼠标按键进行交互。在此左键是用于添加物体。
void mouse_button_callback(GLFWwindow *window, int button, int action, int mods)
{
//左键添加物体
if ((button == GLFW_MOUSE_BUTTON_LEFT && action == GLFW_PRESS) || (button == GLFW_MOUSE_BUTTON_LEFT && action == GLFW_REPEAT))
{
//当处于仰角时不能添加物体
if (camera->pitch >= 0)
{
std::cout << "You can't add object at this camera position" << std::endl;
}
else
{
//获取光标位置,并进行整数化
std::vector<TriMesh *> meshes = painter->getMeshes();
glm::vec3 position = camera->getCenter(0.0);
position.x = (int)position.x;
position.y = (int)position.y;
position.z = (int)position.z;
int temp = meshes.size();
//判断是否可以放置
if (FillUp(position, glm::vec3(1.0), temp))
{
//添加物体并且选择该物体进行移动
addMeshes(position, glm::vec3(0.0, 0.0, 0.0), true, ItemName[ItemIndex], "./assets/item/" + ItemName[ItemIndex] + ".obj",
"./assets/item/" + ItemName[ItemIndex] + ".png", glm::vec3(1.65));
meshes = painter->getMeshes();
MeshIndex = meshes.size() - 1;
mesh = meshes[MeshIndex];
}
}
}
//右键对光标处的物体进行删除
if ((button == GLFW_MOUSE_BUTTON_RIGHT && action == GLFW_PRESS) || (button == GLFW_MOUSE_BUTTON_RIGHT && action == GLFW_REPEAT))
{
//获取光标位置,并进行整数化
glm::vec3 position = camera->getCenter(0.0);
position.x = (int)position.x;
position.y = (int)position.y;
position.z = (int)position.z;
int index = DeleteFill(position); //判断是否能删除
if (index != 0)
{
painter->deleteMesh(index);
}
MeshIndex--;
}
}
首先判断视角是否是仰角,是仰角将不能添加物体。不是仰角时,先获取相机视角下的中心点,此操作是通过getCenter函数进行,getCenter函数如下。
glm::vec3 Camera::getCenter(float y)
{
//获取视角面对的中心位置,参数y为获取y轴哪个高度的坐标。
float dis = eyey / tan(pitch / 180 * PI);
return glm::vec3(eyex + sin(yaw / 180 * PI) * dis, y, eyez - cos(yaw / 180 * PI) * dis);
}
首先是通过tan函数获取视角线在y平面处的投影,再在该平面上从而求出x坐标和z坐标。
获取完毕视角正对的坐标后,将视角进行整数化,因为我们希望物体放置在一个个整数坐标下。接下来用FillUp函数查看是否能够放置坐标,并且更新每个物体的位置。最后添加物体,并且将选择移动的物体变成该物体。
//右键对光标处的物体进行删除
if ((button == GLFW_MOUSE_BUTTON_RIGHT && action == GLFW_PRESS) || (button == GLFW_MOUSE_BUTTON_RIGHT && action == GLFW_REPEAT))
{
//获取光标位置,并进行整数化
glm::vec3 position = camera->getCenter(0.0);
position.x = (int)position.x;
position.y = (int)position.y;
position.z = (int)position.z;
int index = DeleteFill(position); //判断是否能删除
if (index != 0)
{
painter->deleteMesh(index);
}
MeshIndex--;
}
右键是删除指向的物体。同理获取坐标并且整数化,然后判断是否能进行删除操作。通过DeleteFill进行获取,并且删除该坐标的物体。
3. 选择移动、添加的物体
按下数字键1、2键可以选择需要移动的物体。
//若按下1键,控制的是上一个物体,按下的是2键,控制的是下一个物体
case GLFW_KEY_1:
MeshIndex--;
if (MeshIndex <= 1)
MeshIndex = 2;
mesh = meshes[MeshIndex];
meshes.clear();
break;
case GLFW_KEY_2:
MeshIndex++;
if (MeshIndex > meshes.size() - 1)
MeshIndex = meshes.size();
mesh = meshes[MeshIndex];
break;
该操作是通过改变全局指针mesh进行操作,当发生改变时,首先判断是否能进行下一个或上一个物体,并且将相应的物体地址给到mesh当中。
case GLFW_KEY_UP:
if (changeFill(mesh->getTranslation(), mesh->getTranslation() + glm::vec3(0, 0, -1)))
mesh->updateTranslation(2, -1);
break;
case GLFW_KEY_DOWN:
if (changeFill(mesh->getTranslation(), mesh->getTranslation() + glm::vec3(0, 0, 1)))
mesh->updateTranslation(2, 1);
break;
case GLFW_KEY_LEFT:
if (changeFill(mesh->getTranslation(), mesh->getTranslation() + glm::vec3(-1, 0, 0)))
mesh->updateTranslation(0, -1);
break;
case GLFW_KEY_RIGHT:
if (changeFill(mesh->getTranslation(), mesh->getTranslation() + glm::vec3(1, 0, 0)))
mesh->updateTranslation(0, 1);
break;
移动物体的操作通过使用updateTranslation进行操作,并且首先调用changeFill。
//选择需要添加的物体
case GLFW_KEY_Q:
if (ItemIndex > 0)
ItemIndex--;
std::cout << "Slected Add Object is: " << ItemName[ItemIndex] << std::endl;
break;
case GLFW_KEY_E:
if (ItemIndex < 10)
ItemIndex++;
std::cout << "Slected Add Object is: " << ItemName[ItemIndex] << std::endl;
break;
Q和E键可以改变添加的物体,原理与上述差不多,通过改变ItemIndex进行实现。
4. 光源位置改变
可以通过使用UJIKOL六个按键对光源的位置进行调整。
//更改灯光位置
case GLFW_KEY_U:
pos = light->getTranslation();
pos.x += 0.5;
light->setTranslation(pos);
std::cout << "Light Position:" << pos.x << " " << pos.y << " " << pos.z << std::endl;
break;
case GLFW_KEY_J:
pos = light->getTranslation();
pos.x -= 0.5;
light->setTranslation(pos);
std::cout << "Light Position:" << pos.x << " " << pos.y << " " << pos.z << std::endl;
break;
case GLFW_KEY_I:
pos = light->getTranslation();
pos.y += 0.5;
light->setTranslation(pos);
std::cout << "Light Position:" << pos.x << " " << pos.y << " " << pos.z << std::endl;
break;
case GLFW_KEY_K:
pos = light->getTranslation();
if (pos.y >= 20)
pos.y -= 0.5;
light->setTranslation(pos);
std::cout << "Light Position:" << pos.x << " " << pos.y << " " << pos.z << std::endl;
break;
case GLFW_KEY_O:
pos = light->getTranslation();
pos.z += 0.5;
light->setTranslation(pos);
std::cout << "Light Position:" << pos.x << " " << pos.y << " " << pos.z << std::endl;
break;
case GLFW_KEY_L:
pos = light->getTranslation();
pos.z -= 0.5;
light->setTranslation(pos);
std::cout << "Light Position:" << pos.x << " " << pos.y << " " << pos.z << std::endl;
break;
使用light的setTranslation进行实现,并且在y方向上光源不得低于20。
5. 层级建模交互
按下小键盘Enter键可以使得层级建模模型进行移动,按下小键盘数组键可以选择需要转动的部位,并且通过按键z和x控制转动角度。
//移动机器人
case GLFW_KEY_KP_ENTER:
if (ManModeFlag == 0)
mesh = Man[robot.Body];
std::cout << "Select Moving Object is Man" << std::endl;
break;
//选择旋转机器人部件
case GLFW_KEY_KP_0:
Selected_mesh = robot.Body;
break;
case GLFW_KEY_KP_1:
Selected_mesh = robot.Head;
break;
case GLFW_KEY_KP_2:
Selected_mesh = robot.RightBigArm;
break;
case GLFW_KEY_KP_3:
Selected_mesh = robot.LeftBigArm;
break;
case GLFW_KEY_KP_4:
Selected_mesh = robot.RightSmallArm;
break;
case GLFW_KEY_KP_5:
Selected_mesh = robot.LeftSmallArm;
break;
case GLFW_KEY_KP_6:
Selected_mesh = robot.RightHand;
break;
case GLFW_KEY_KP_7:
Selected_mesh = robot.LeftHand;
break;
case GLFW_KEY_KP_8:
Selected_mesh = robot.RightSword;
break;
case GLFW_KEY_KP_9:
Selected_mesh = robot.LeftSword;
break;
// 通过按键旋转
case GLFW_KEY_Z:
robot.theta[Selected_mesh] += 5.0;
if (robot.theta[Selected_mesh] > 360.0)
robot.theta[Selected_mesh] -= 360.0;
break;
case GLFW_KEY_X:
robot.theta[Selected_mesh] -= 5.0;
if (robot.theta[Selected_mesh] < 0.0)
robot.theta[Selected_mesh] += 360.0;
break;
9. 其余功能
1. 视角跟随机器人模式
按下按键M可以进入机器人视角跟随模式。
//启动相机跟随物体模式第二次按下取消
case GLFW_KEY_M:
if (ManModeFlag == 0)
{
std::cout << "Enable Man Moving Mode" << std::endl;
ManModeFlag = 1;
mesh = meshes[MeshIndex]; //移动物体恢复成移动其他物体
//将相机移动到机器人头部的视角处
camera->eyex = Man[robot.Body]->getTranslation().x;
camera->eyey = Man[robot.Body]->getTranslation().y + 2;
camera->eyez = Man[robot.Body]->getTranslation().z;
camera->yaw = -robot.theta[robot.Head];
camera->pitch = Man[robot.Head]->getRotation().x;
Man[robot.Body]->setTranslation(glm::vec3(0.0, 0.0, 0.0));
//这个将物体归0是因为执行以后,相机坐标移动到机器人处,然后机器人处又会根据相机进一步移动,所以先归0
}
else
{
std::cout << "Disable Man Moving Mode" << std::endl;
ManModeFlag = 0;
robot.theta[robot.Head] = robot.theta[robot.Head] - robot.theta[robot.Body];
Man[robot.Body]->setTranslation(glm::vec3(camera->eyex, 0.0, camera->eyez));
//保存机器人当前位置
}
break;
这里是使用ManModeFlag来进行控制,当按下时,将视角转移到机器人头部处,并且视角的pitch轴和yaw轴角度与机器人头部的x轴和y轴相同,并且设置ManModeFlag为1。
关闭时,将ManModeFlag设置为0,并且保存机器人当前位置。
在DrawMulMesh当中,当开启视角跟随模式后,会将视角与机器人的身体和头部进行绑定。
if (ManModeFlag == true)
{
//当跟随模式开启,躯干的位置就是相机的位置,并且若头部和身体相差30度时,身体也跟随一起转动
modelMatrix = glm::translate(modelMatrix, glm::vec3(camera->eyex, 0.0, camera->eyez));
if (robot.theta[robot.Head] - robot.theta[robot.Body] > 30)
robot.theta[robot.Body] = robot.theta[robot.Head] - 30;
if (robot.theta[robot.Head] - robot.theta[robot.Body] < -30)
robot.theta[robot.Body] = robot.theta[robot.Head] + 30;
}
else
modelMatrix = glm::translate(modelMatrix, glm::vec3(0.0, 0.0, 0.0));
首先,身体部位是在头部与身体产生大于30度偏角时,进行旋转。并且身体的位置与相机进行绑定。
头部是与相机的pitch轴和yaw轴进行绑定,这样子机器人就可以随时跟随相机视角了。

2. 光标制作
在写相机跟随模式时,我发现头部是一直可以固定在中间位置的,从而有了启发,可以像头部一样将光标与相机绑定,这样就可以做出一个类似的光标。
与之前原理相同,将物体绑定在painter当中,同时也是与Ground一样不使用阴影。接着在display函数当中将物体与视角进行绑定。
glm::mat4 modelMatrix = Cross->getModelMatrix();
modelMatrix = glm::translate(modelMatrix, glm::vec3(camera->eyex, camera->eyey, camera->eyez));
modelMatrix = glm::rotate(modelMatrix, glm::radians(-camera->yaw), glm::vec3(0.0, 1.0, 0.0));
modelMatrix = glm::rotate(modelMatrix, glm::radians(-camera->pitch), glm::vec3(1.0, 0.0, 0.0));
painter->drawMesh(Cross, CrossObject, light, camera, modelMatrix, 0);

本文详细介绍了使用OpenGL和glfw库实现简单Minecraft游戏的过程,包括OBJ文件读取、物体渲染与纹理着色、相机变换、光照与阴影、交互功能如视角移动、物体添加删除等。通过代码示例展示了如何实现层级建模和动画,并提供了完整的交互功能,如鼠标按键操作和键盘控制。
2343

被折叠的 条评论
为什么被折叠?



