这个系列文章我们来介绍一位海外工程师如何探索 OpenGL 音视频渲染技术,对于想要开始学习音视频技术的朋友,这些文章是份不错的入门资料,这是第 6 篇:OpenGL 纹理。
—— 来自公众号“关键帧Keyframe”的分享
我们已经了解到,为了给对象添加更多细节,我们可以为每个顶点指定颜色,从而创建一些有趣的图像。然而,为了获得一定的真实感,我们不得不拥有许多顶点,以便能够指定许多颜色。这带来了相当大的额外开销,因为每个模型需要更多的顶点,每个顶点还需要一个颜色属性。
艺术家和程序员通常更喜欢使用纹理。纹理是一个 2D 图像(也存在 1D 和 3D 纹理),用于为对象添加细节;可以想象纹理是一张印有漂亮砖块图像的纸,整齐地折叠在你的 3D 房子上,使其看起来像是有石头外墙的房子。由于我们可以在单个图像中插入大量细节,我们可以通过指定额外的顶点来给人一种对象极其详细的错觉。
除了图像,纹理还可以用于存储大量任意数据以发送到着色器,但我们将把这个主题留到以后。
下面你将看到上一章中映射到三角形的砖墙纹理图像。

为了将纹理映射到三角形,我们需要告诉三角形的每个顶点它对应纹理的哪一部分。每个顶点因此应该有一个与之关联的纹理坐标,该坐标指定要从纹理图像的哪个部分采样。片段插值然后为其他片段完成其余工作。
纹理坐标在 x
和 y
轴上从 0
到 1
范围变化(记住我们使用的是 2D 纹理图像)。使用纹理坐标检索纹理颜色的过程称为采样。纹理坐标的左下角从 (0,0)
开始,右上角到 (1,1)
。下图显示了我们如何将纹理坐标映射到三角形:

我们为三角形指定了 3 个纹理坐标点。我们希望三角形的左下角对应纹理的左下角,因此我们为三角形的左下顶点使用 (0,0)
纹理坐标。同理,右下角使用 (1,0)
纹理坐标。三角形的顶部应对应纹理图像的顶部中心,因此我们将其纹理坐标设为 (0.5,1.0)
。我们只需要将 3 个纹理坐标传递给顶点着色器,然后传递给片段着色器,片段着色器会为每个片段插值所有纹理坐标。
结果纹理坐标将如下所示:
float texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 顶部中心角
};
纹理采样有松散的解释,可以以多种不同的方式进行。因此,我们有责任告诉 OpenGL 应如何采样其纹理。
1、纹理环绕
纹理坐标通常在 (0,0)
到 (1,1)
范围内,但如果我们指定范围外的坐标会发生什么?OpenGL 的默认行为是重复纹理图像(我们基本上忽略了浮点纹理坐标的整数部分),但 OpenGL 提供了更多选项:
GL_REPEAT
:纹理的默认行为。重复纹理图像。GL_MIRRORED_REPEAT
:与GL_REPEAT
相同,但每次重复时镜像图像。GL_CLAMP_TO_EDGE
:将坐标限制在0
和1
之间。结果是更高坐标被限制为边缘,导致拉伸的边缘图案。GL_CLAMP_TO_BORDER
:范围外的坐标现在被赋予用户指定的边框颜色。
当使用超出默认范围的纹理坐标时,每个选项在样本纹理图像上都有不同的视觉输出(原始图像由 Hólger Rezende 提供):

可以通过 glTexParameter*
函数为每个坐标轴(s
、t
(以及如果你使用 3D 纹理的 r
,分别相当于 x
、y
、z
)设置上述选项中的一个:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
第一个参数指定纹理目标;我们正在处理 2D 纹理,因此纹理目标是 GL_TEXTURE_2D
。第二个参数要求我们告诉要设置哪个选项以及为哪个纹理轴设置;我们希望为 S
和 T
轴配置它。最后一个参数要求我们传入我们想要的纹理环绕模式,在这种情况下,OpenGL 将为当前活动纹理设置为 GL_MIRRORED_REPEAT
的纹理环绕选项。
如果我们选择 GL_CLAMP_TO_BORDER
选项,我们还应该指定一个边框颜色。这可以通过使用 glTexParameterfv
函数的 fv
等效函数来完成,其选项为 GL_TEXTURE_BORDER_COLOR
,我们传入边框颜色值的浮点数组:
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
2、纹理过滤
纹理坐标不依赖于分辨率,但可以是任何浮点值,因此 OpenGL 必须确定将纹理坐标映射到哪个纹理像素(也称为 texel)。这在你有一个非常大的对象和一个低分辨率纹理时变得尤为重要。你可能已经猜到了,OpenGL 也有纹理过滤的选项。有几个选项可用,但目前我们只讨论最重要的两个选项:GL_NEAREST
和 GL_LINEAR
。
GL_NEAREST
(也称为最近邻或点过滤)是 OpenGL 的默认纹理过滤方法。当设置为 GL_NEAREST
时,OpenGL 选择中心最接近纹理坐标的 texel。下面你可以看到 4 个像素,其中十字表示确切的纹理坐标。左上 texel 的中心最接近纹理坐标,因此被选为采样的颜色:

GL_LINEAR
(也称为(双)线性过滤)从纹理坐标的邻近 texel 获取插值值,近似 texel 之间的颜色。纹理坐标与 texel 中心的距离越小,该 texel 的颜色对采样颜色的贡献就越大。下面我们可以看到返回了邻近像素的混合颜色:

但是,这样的纹理过滤方法的视觉效果是什么样的呢?让我们看看当使用低分辨率纹理在大型对象上时(因此纹理被放大,单个 texel 是可见的)这些方法是如何工作的:

GL_NEAREST
产生块状图案,我们可以清楚地看到形成纹理的像素,而 GL_LINEAR
产生更平滑的图案,单个像素不太可见。GL_LINEAR
产生更真实的输出,但一些开发者更喜欢更 8 位的外观,因此选择 GL_NEAREST
选项。
纹理过滤可以为放大和缩小操作(向上或向下缩放时)设置,因此例如,当纹理向下缩放时可以使用最近邻过滤,而向上缩放的纹理使用线性过滤。因此,我们必须通过 glTexParameter*
为这两种操作指定过滤方法。代码应类似于设置环绕方法:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2.1、Mipmaps
想象我们有一个大房间,里面有成千上万的对象,每个对象都有一个附加的纹理。远处的对象将附有与观众近处对象相同的高分辨率纹理。由于对象很远,可能只产生几个片段,OpenGL 难以为片段从高分辨率纹理中检索正确的颜色值,因为它必须为一个覆盖纹理大部分区域的片段选择一个纹理颜色。这将在小对象上产生可见的伪影,更不用说在小对象上使用高分辨率纹理浪费的内存带宽了。
为了解决这个问题,OpenGL 使用了一个称为 mipmap 的概念,这基本上是一组纹理图像,其中每个后续纹理比前一个纹理小两倍。mipmap 的想法应该很容易理解:在观众的某个距离阈值之后,OpenGL 将使用最适合该距离的不同的 mipmap 纹理。因为对象很远,较小的分辨率不会被用户注意到。OpenGL 然后能够正确采样 texel,并且在采样 mipmap 的该部分时涉及的缓存内存更少。让我们仔细看看 mipmap 纹理是什么样子:
为每个纹理图像创建 mipmap 纹理集合手动操作很麻烦,但幸运的是,OpenGL 可以通过在创建纹理后调用 glGenerateMipmap
来为我们完成所有工作。
在渲染过程中在 mipmap 级别之间切换时,OpenGL 可能会显示一些伪影,比如在两个 mipmap 层之间可见的锐利边缘。与普通纹理过滤类似,也可以在 mipmap 级别之间使用 NEAREST 和 LINEAR 过滤来切换。为了指定 mipmap 级别之间的过滤方法,我们可以将原始过滤方法替换为以下四种选项之一:
GL_NEAREST_MIPMAP_NEAREST
:选择最接近像素大小的 mipmap,并使用最近邻插值进行纹理采样。GL_LINEAR_MIPMAP_NEAREST
:选择最近的 mipmap 级别,并使用线性插值对该级别进行采样。GL_NEAREST_MIPMAP_LINEAR
:在线性插值两个最接近像素大小的 mipmap 之间,并通过最近邻插值对插值后的级别进行采样。GL_LINEAR_MIPMAP_LINEAR
:在线性插值两个最接近的 mipmap 之间,并通过线性插值对插值后的级别进行采样。
与纹理过滤类似,我们可以通过 glTexParameteri
将过滤方法设置为上述四种方法之一:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是将其中一个 mipmap 过滤选项设置为放大过滤器。这没有任何效果,因为 mipmap 主要用于纹理向下缩放:纹理放大不使用 mipmap,给它一个 mipmap 过滤选项将生成一个 OpenGL GL_INVALID_ENUM
错误代码。
3、加载和创建纹理
为了实际使用纹理,我们首先需要将它们加载到我们的应用程序中。 纹理图像可以存储在几十种文件格式中,每种格式都有自己的结构和数据顺序,那么我们如何将这些图像加载到我们的应用程序中呢?一个解决方案是选择我们想要使用的文件格式,比如 .PNG
,然后编写我们自己的图像加载器,将图像格式转换为一个大的字节数组。虽然编写自己的图像加载器并不非常困难,但仍然很麻烦,如果你想支持更多文件格式呢?你将不得不为每个你想要支持的格式编写一个图像加载器。
另一个解决方案,可能是一个好的解决方案,是使用一个支持多种流行格式的图像加载库,并为我们完成所有艰苦的工作。一个像 stb_image.h
这样的库。
4、stb_image.h
stb_image.h
是 Sean Barrett 编写的一个非常流行的单头图像加载库,能够加载大多数流行的文件格式,并且易于集成到你的项目中。stb_image.h
可以从这里下载。只需下载单个头文件,将其添加到你的项目中作为 stb_image.h
,并创建一个额外的 C++ 文件,包含以下代码:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
通过定义 STB_IMAGE_IMPLEMENTATION
,预处理器修改头文件,使其仅包含相关的定义源代码,有效地将头文件变成一个 .cpp
文件,仅此而已。现在只需在程序中包含 stb_image.h
并编译即可。
在接下来的纹理部分中,我们将使用一个木箱的图像。 要使用 stb_image.h
加载图像,我们使用其 stbi_load
函数:
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
该函数首先以图像文件的位置作为输入。然后它期望你提供三个 int
作为其第二、第三和第四个参数,stb_image.h
将用结果图像的宽度、高度和颜色通道数量填充这些参数。我们需要图像的宽度和高度以便稍后生成纹理。
5、生成纹理
与 OpenGL 中的任何其他对象一样,纹理通过 ID 引用;让我们创建一个:
unsigned int texture;
glGenTextures(1, &texture);
glGenTextures
函数首先以我们想要生成的纹理数量作为输入,并将它们存储在作为第二个参数提供的 unsigned int
数组中(在我们的例子中只是一个单独的 unsigned int
)。与其他对象一样,我们需要绑定它,以便任何后续的纹理命令都将配置当前绑定的纹理:
glBindTexture(GL_TEXTURE_2D, texture);
现在纹理已经绑定,我们可以通过之前加载的图像数据开始生成纹理。使用 glTexImage2D
生成纹理:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
这是一个参数众多的大型函数,所以我们逐步介绍:
- 第一个参数指定纹理目标;将其设置为
GL_TEXTURE_2D
意味着此操作将在当前绑定到同一目标的纹理对象上生成纹理(因此,任何绑定到GL_TEXTURE_1D
或GL_TEXTURE_3D
目标的纹理将不会受到影响)。 - 第二个参数指定如果我们想手动设置每个 mipmap 级别,则为其创建纹理的 mipmap 级别,但我们将其保留为基础级别
0
。 - 第三个参数告诉 OpenGL 我们希望以何种格式存储纹理。我们的图像只有
RGB
值,因此我们也以RGB
值存储纹理。 - 第四和第五个参数设置结果纹理的宽度和高度。我们在加载图像时存储了这些值,因此我们将使用相应的变量。
- 下一个参数应始终为
0
(一些遗留内容)。 - 第七和第八个参数指定源图像的格式和数据类型。我们以
RGB
值加载图像,并将其存储为char
(字节),因此我们传入相应的值。 - 最后一个参数是实际的图像数据。
调用 glTexImage2D
后,当前绑定的纹理对象现在附加了纹理图像。然而,目前它只加载了基础级别的纹理图像,如果我们想要使用 mipmap,我们必须手动指定所有不同的图像(通过不断增加第二个参数),或者,我们可以在生成纹理后调用 glGenerateMipmap
。这将自动为当前绑定的纹理生成所有所需的 mipmap。
在我们完成生成纹理及其对应的 mipmap 后,释放图像内存是一个良好的做法:
stbi_image_free(data);
生成纹理的整个过程因此看起来像这样:
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// 设置纹理的环绕/过滤选项(在当前绑定的纹理对象上)
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_LINEAR);
// 加载并生成纹理
int width, height, nrChannels;
unsignedchar *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);
6、应用纹理
在接下来的部分中,我们将使用《三角形入门》一章最后部分中用 glDrawElements
绘制的矩形形状。 我们需要告知 OpenGL 如何采样纹理,因此我们需要使用纹理坐标更新顶点数据:
float vertices[] = {
// 位置 // 颜色 // 纹理坐标
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上
};
由于我们添加了一个额外的顶点属性,我们还需要再次通知 OpenGL 新的顶点格式:
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
请注意,我们还需要将前两个顶点属性的步长参数调整为 8 * sizeof(float)
。
接下来,我们需要修改顶点着色器以接受纹理坐标作为顶点属性,然后将坐标传递给片段着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;
out vec3 ourColor;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
TexCoord = aTexCoord;
}
片段着色器应该将 TexCoord
输出变量作为输入变量接受。
片段着色器还需要访问纹理对象,但我们如何将纹理对象传递给片段着色器呢?GLSL 有一个内置的数据类型,用于纹理对象,称为 sampler,它以我们想要的纹理类型作为后缀,例如 sampler1D
、sampler3D
或在我们的情况下 sampler2D
。我们可以通过简单地声明一个 uniform sampler2D
来将纹理添加到片段着色器中,稍后我们将纹理分配给它。
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
为了对纹理进行采样,我们使用 GLSL 的内置 texture 函数,它以纹理 sampler 作为第一个参数,对应的纹理坐标作为第二个参数。texture 函数然后使用我们之前设置的纹理参数对相应的颜色值进行采样。此片段着色器的输出是纹理在(插值的)纹理坐标处的(过滤的)颜色。
现在剩下的唯一要做的就是在调用 glDrawElements
之前绑定纹理,它将自动将纹理分配给片段着色器的 sampler:
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
如果你一切都做对了,你应该看到以下图像:
如果你的矩形完全是白色或黑色,你可能在过程中犯了错误。检查你的着色器日志,并尝试将你的代码与应用程序的源代码进行比较。
如果你的纹理代码不起作用或显示为完全黑色,请继续阅读并尝试最后一个应该起作用的例子。在某些驱动程序上,为每个 sampler uniform 分配纹理单元是必需的,这将在本章后面讨论。
为了更有趣,我们还可以将结果纹理颜色与顶点颜色混合。我们只需在片段着色器中将结果纹理颜色乘以顶点颜色以混合两种颜色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
结果应该是顶点颜色和纹理颜色的混合:
你可以说我们的箱子喜欢迪斯科。
7、纹理单元
你可能想知道为什么 sampler2D
变量是一个 uniform,即使我们没有使用 glUniform
为其分配任何值。实际上,我们可以使用 glUniform1i
为纹理 sampler 分配一个位置值,这样我们就可以在片段着色器中同时设置多个纹理。这个纹理的位置更通常被称为纹理单元。纹理的默认纹理单元是 0
,这是默认激活的纹理单元,因此我们不需要在上一节中分配位置;请注意,并非所有图形驱动程序都分配默认纹理单元,因此上一节可能没有为你渲染。
纹理单元的主要目的是允许我们在着色器中使用多个纹理。通过为 samplers 分配纹理单元,我们可以在激活相应的纹理单元后同时绑定多个纹理。与 glBindTexture
类似,我们可以通过 glActiveTexture
激活纹理单元,传入我们想要使用的纹理单元:
glActiveTexture(GL_TEXTURE0); // 激活纹理单元,然后绑定纹理
glBindTexture(GL_TEXTURE_2D, texture);
激活纹理单元后,后续的 glBindTexture
调用将把纹理绑定到当前激活的纹理单元。GL_TEXTURE0
纹理单元默认始终激活,因此在上一个示例中使用 glBindTexture
时,我们不需要激活任何纹理单元。
OpenGL 应该至少有 16 个纹理单元供你使用,你可以通过 GL_TEXTURE0
到 GL_TEXTURE15
激活它们。它们按顺序定义,因此例如我们也可以通过 GL_TEXTURE0 + 8
获得 GL_TEXTURE8
,这在我们需要循环遍历多个纹理单元时非常有用。
我们仍然需要修改片段着色器以接受另一个 sampler。这应该相对简单:
#version 330 core
...
uniform sampler2D texture1;
uniform sampler2D texture2;
void main()
{
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}
最终输出颜色现在是两种纹理查找的组合。GLSL 的内置 mix 函数接受两个值作为输入,并根据其第三个参数进行线性插值。如果第三个值是 0.0
,它返回第一个输入;如果是 1.0
,它返回第二个输入值。0.2
的值将返回 80%
的第一个输入颜色和 20%
的第二个输入颜色,从而产生我们两种纹理的混合。
现在我们想要加载并创建另一个纹理;你应该对这些步骤很熟悉了。确保创建另一个纹理对象,加载图像,并使用 glTexImage2D
生成最终纹理。对于第二个纹理,我们将使用一个包含你在学习 OpenGL 时面部表情的图像:
unsigned char *data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
请注意,我们现在加载了一个包含 alpha(透明度)通道的 .png
图像。这意味着我们还需要通过使用 GL_RGBA
指定图像数据包含 alpha 通道,否则 OpenGL 将错误地解释图像数据。
为了使用第二个纹理(和第一个纹理),我们需要稍微改变渲染过程,将两个纹理绑定到相应的纹理单元:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
我们还需要告诉 OpenGL 每个着色器 sampler 属于哪个纹理单元,通过使用 glUniform1i
设置每个 sampler。我们只需要设置一次,因此可以在进入渲染循环之前完成:
ourShader.use(); // 别忘了在设置 uniform 之前激活着色器!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手动设置
ourShader.setInt("texture2", 1); // 或使用着色器类
通过 glUniform1i
设置 samplers,我们确保每个 uniform sampler 对应正确的纹理单元。你应该得到以下结果:
你可能已经注意到纹理上下颠倒了!这是因为 OpenGL 期望 y 轴的 0.0
坐标在图像的底部,而图像通常在 y 轴的顶部有 0.0
。幸运的是,stb_image.h
可以在加载图像时通过添加以下语句来翻转 y 轴:
stbi_set_flip_vertically_on_load(true);
在告诉 stb_image.h
在加载图像时翻转 y 轴后,你应该得到以下结果:
如果你看到一个开心的箱子,你做对了。你可以将它与源代码进行比较。
8、练习
为了更熟悉纹理,建议在继续之前完成这些练习。
- 确保只有笑脸面向相反/反方向,通过更改片段着色器:解决方案。
- 通过将纹理坐标指定在
0.0f
到2.0f
范围内而不是0.0f
到1.0f
来实验不同的纹理环绕方法。看看你是否可以在一个容器图像的边缘处显示 4 个笑脸:解决方案,结果。看看你是否也可以实验其他环绕方法。 - 尝试通过更改纹理坐标仅在矩形上显示纹理图像的中心像素,使单个像素变得可见。尝试将纹理过滤方法设置为
GL_NEAREST
以更清晰地看到像素:解决方案。 - 使用 uniform 变量作为 mix 函数的第三个参数来改变两种纹理的可见程度。使用上下箭头键来改变箱子或笑脸的可见程度:解决方案。
音视频方向学习、求职,欢迎加入我们的星球
丰富的音视频知识、面试题、技术方案干货分享,还可以进行面试辅导

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。