从零开始写3D游戏引擎(开发环境VS2022+OpenGL)之六 如何给自己的图形添加纹理,包含代码与解释的保姆包教会系列

很好,之前已经介绍了如何在VS2022+OpenGL环境中,创建一个简单三角形,给三角形添加着色器,如何修改着色器,在新的课程开始之前,如果对这些内容存在疑问,可以参看我的博客文章,我的优快云博客名称为金沙阳,最近三篇博文的链接如下:

从零开始写游戏引擎(开发环境VS2022+OpenGL)之三 HelloWorld窗口的实现,代码与解释,步步教会系列_写个游戏引擎-优快云博客

从零开始写游戏引擎(开发环境VS2022+OpenGL)之四 GPU简单作图的实现,代码与解释,保姆包教会系列-优快云博客

从零开始写游戏引擎(开发环境VS2022+OpenGL)之五 如何编写自己的着色器,保姆包教会系列-优快云博客

今天的目标是为了在四边形上面添加纹理,效果图如下:

在这里插入图片描述

好了下面开始介绍如何在自己的图形里面添加纹理。

纹理(Textures)

我们都知道哦,为了给我们绘制的对象,添加更多的细节,我们可以为每个顶点使用颜色,来创建一些有趣的图像。然而,为了获得相当的真实感,我们必须有很多顶点,这样我们就可以指定很多颜色。这需要相当多的额外开销,因为每个模型需要更多的顶点,每个顶点也需要一个颜色属性。

美工和程序员通常更喜欢使用纹理。纹理是用于为对象添加细节的2D图像(甚至存在1D和3D纹理);把纹理想象成一张纸,上面有漂亮的砖块图像(这里就举个例子哈),整齐地折叠在你的3D房子上,这样你的房子看起来就像石头一样。因为我们可以在一张图像中插入很多细节,我们可以给人一种,这个物体有非常多细节的错觉,而不必指定额外的顶点。

[!NOTE]

除了图像,纹理也可以用来存储大量的任意数据来发送给着色器,但是这个就属于不同的主题了,我们遇到再来说。

下面你会看到一个砖墙的纹理图像映射到前一章的三角形中的样子。

在这里插入图片描述

为了将纹理映射到三角形,我们需要告诉三角形的每个顶点它对应的是纹理的哪个部分。因此,每个顶点都应该有一个与之相关的纹理坐标,以指定从纹理图像的哪个部分进行采样。片段插值然后为其他片段做剩下的工作。

纹理坐标在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,  // lower-left corner  
    1.0f, 0.0f,  // lower-right corner
    0.5f, 1.0f   // top-center corner
};

纹理采样有一个松散的解释,可以用许多不同的方式来完成。因此,我们的工作是告诉OpenGL它应该如何采样它的纹理。

纹理环绕(Texture Wrapping)

外景坐标的范围通常从(0,0)到(1,1),但如果我们指定的坐标超出这个范围会发生什么?OpenGL的默认行为是重复纹理图像(我们基本上忽略了浮点纹理坐标的整数部分),但OpenGL提供了更多选项:

[!IMPORTANT]

GL_REPEAT:纹理的默认行为。重复纹理图像。

GL_MIRRORRED_REPEAT:与GL_REPEAT相同,但每次重复都会镜像图像。

GL_CLAMP_TO_EDGE:夹住0到1之间的坐标。结果是更高的坐标被固定在边缘上,导致拉伸的边缘图案。

GL_CLAMP_TO_BORDER:范围外的坐标现在被赋予用户指定的边框颜色。

当使用默认范围之外的纹理坐标时,每个选项都有不同的视觉输出。让我们看看它们在纹理图像样本上的样子:

在这里插入图片描述

上面提到的每个选项都可以通过glTexParameter*函数设置每个坐标轴(s, t(还有r,如果你使用的是3D纹理的话),相当于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选项,我们还应该指定边框颜色。这是使用fv等效的glTexParameter函数来完成的,GL_TEXTURE_BORDER_COLOR作为它的选项,我们传入一个浮点数组的边界颜色值:

float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);  

纹理滤波(Texture Filtering)

纹理坐标不依赖于分辨率,但可以是任何浮点值,因此OpenGL必须找出将纹理坐标映射到哪个纹理像素(也称为texel)。如果你有一个非常大的物体和一个低分辨率的纹理,这变得尤其重要。现在你可能已经猜到OpenGL也有这个纹理过滤的选项。有几个可用的选项,但现在我们将讨论最重要的选项:GL_NEAREST和GL_LINEAR。

GL_NEAREST(也称为最近邻或点过滤)是OpenGL默认的纹理过滤方法。当设置为GL_NEAREST时,OpenGL选择中心最接近纹理坐标的texel。下面你可以看到4个像素,其中十字表示确切的纹理坐标。左上角纹理的中心最接近纹理坐标,因此被选为采样颜色:
在这里插入图片描述

GL_LINEAR(也称为(bi)线性滤波)从纹理坐标的邻近体素中获取插值值,近似于体素之间的颜色。纹理坐标到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);

Mipmaps(抱歉,这个我没有找到适合的中文翻译)

想象一下,我们有一个大房间,里面有成千上万的物体,每个物体都有一个附加的纹理。远处的物体会有与靠近观察者的物体相同的高分辨率纹理。由于物体距离较远,可能只产生一些碎片,OpenGL很难从高分辨率纹理中为其碎片检索正确的颜色值,因为它必须为跨越纹理大部分的碎片选择纹理颜色。这将在小物体上产生可见的伪影,更不用说在小物体上使用高分辨率纹理会浪费内存带宽。

为了解决这个问题,OpenGL使用了一个叫做mipmaps的概念,它基本上是纹理图像的集合,其中每个后续纹理比前一个纹理小两倍。mipmap背后的想法应该很容易理解:在距离观察者一定的距离阈值之后,OpenGL将使用最适合物体距离的不同mipmap纹理。由于物体距离较远,用户不会注意到较小的分辨率。然后OpenGL能够采样正确的texels,并且在采样mipmap的那部分时涉及的缓存内存更少。让我们仔细看看贴图纹理是什么样子的:

在这里插入图片描述

手动为每个纹理图像创建一组mimapping纹理是很麻烦的,但幸运的是,OpenGL能够在我们创建纹理后通过调用glGenerateMipmap为我们完成所有的工作。

在渲染过程中,当在mipmap层之间切换时,OpenGL可能会显示一些工件,例如两个mipmap层之间可见的锐边。就像普通的纹理过滤一样,也可以使用NEAREST和LINEAR过滤在mipmap级别之间进行过滤,以便在mipmap级别之间切换。要指定在mipmap级别之间的过滤方法,我们可以用以下四个选项之一替换原来的过滤方法:

[!IMPORTANT]

GL_NEAREST_MIPMAP_NEAREST:取最近的mipmap来匹配像素大小,并使用最近邻插值进行纹理采样。

GL_LINEAR_MIPMAP_NEAREST:获取最近的mipmap级别,并使用线性插值对该级别进行采样。

GL_NEAREST_MIPMAP_LINEAR:在两个最接近像素大小的mipmap之间进行线性插值,并通过最近邻插值对插值后的水平进行采样。

GL_LINEAR_MIPMAP_LINEAR:在两个最接近的mipmap之间进行线性插值,并通过线性插值对插值后的水平进行采样。

就像纹理过滤一样,我们可以使用glTexParameteri将过滤方法设置为前面提到的4种方法之一:

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错误代码。

加载和创建纹理

要真正使用纹理,我们需要做的第一件事是将它们加载到我们的应用程序中。纹理图像可以存储在几十种文件格式中,每种格式都有自己的结构和数据顺序,那么我们如何在应用程序中获取这些图像呢?一种解决方案是选择一种我们想要使用的文件格式,比如.png,然后编写我们自己的图像加载程序来将图像格式转换为一个大的字节数组。虽然编写自己的图像加载程序并不难,但仍然很麻烦,如果您想支持更多的文件格式怎么办?然后,您必须为要支持的每种格式编写图像加载程序。

另一个解决方案,可能是一个很好的解决方案,是使用一个图像加载库,它支持几种流行的格式,并为我们做了所有艰苦的工作。像stb_image.h这样的库。

stb_image.h

stb_image.h是一个非常流行的单头图像加载库,由肖恩巴雷特编写,能够加载最流行的文件格式,很容易集成到您的项目中。Stb_image.h可以从这里下载。

链接为stb-image.h是一个非常流行的单头图像加载库,能够加载最流行的文件格式,很容易集成到您的项目中Stb-image.h可以从这里下载只需下载单个头文件,将其作为stb-im资源-优快云文库

只需下载单个头文件,将其作为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将填充结果图像的宽度、高度和颜色通道数。我们需要图像的宽度和高度,以便稍后生成纹理。

纹理的产生

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

这是一个很大的函数,有很多参数,所以我们将一步一步地完成它们:

[!IMPORTANT]

第一个参数指定纹理目标;将此设置为GL_TEXTURE_2D意味着此操作将在相同目标上当前绑定的纹理对象上生成纹理(因此绑定到目标GL_TEXTURE_1D或GL_TEXTURE_3D的任何纹理都不会受到影响)。

第二个参数指定我们想要创建纹理的mipmap级别,如果你想手动设置每个mipmap级别,但我们将把它留在基础级别,即0。

第三个参数告诉OpenGL我们希望以哪种格式存储纹理。我们的图像只有RGB值,所以我们也会用RGB值来存储纹理。

第4和第5个参数设置结果纹理的宽度和高度。我们之前在加载图像时存储了这些变量,因此我们将使用相应的变量。

下一个参数应该总是0(一些遗留的东西)。

第7和第8个参数指定源图像的格式和数据类型。我们用RGB值加载图像并将其存储为字符(字节),因此我们将传递相应的值。

最后一个参数是实际的图像数据。

一旦调用了glTexImage2D,当前绑定的纹理对象就有了附加的纹理图像。然而,目前它只加载了纹理图像的基本级别,如果我们想使用mipmaps,我们必须手动指定所有不同的图像(通过不断增加第二个参数),或者,我们可以在生成纹理后调用glGenerateMipmap。这将自动为当前绑定的纹理生成所有所需的贴图。

在我们完成生成纹理和相应的贴图之后,释放图像内存是一个很好的做法:

stbi_image_free(data);

生成纹理的整个过程是这样的:

unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
// set the texture wrapping/filtering options (on the currently bound texture object)
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);
// load and generate the texture
int width, height, nrChannels;
unsigned char *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);

应用纹理

在接下来的章节中,我们将使用来自之前章节最后一部分的glDrawElements绘制的矩形形状。

[!TIP]

可以参看我的这一篇博文从零开始写游戏引擎(开发环境VS2022+OpenGL)之五 如何编写自己的着色器,保姆包教会系列-优快云博客,如果您忘记的话。

我们需要告知OpenGL如何采样纹理,这样我们就必须用纹理坐标更新顶点数据:

float vertices[] = {
    // positions          // colors           // texture coords
     0.5f,  0.5f, 0.0f,   1.0f, 0.0f, 0.0f,   1.0f, 1.0f,   // top right
     0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,   1.0f, 0.0f,   // bottom right
    -0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 0.0f,   // bottom left
    -0.5f,  0.5f, 0.0f,   1.0f, 1.0f, 0.0f,   0.0f, 1.0f    // top left 
};

由于我们添加了一个额外的顶点属性,我们必须再次通知OpenGL新的顶点格式:

在这里插入图片描述

glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);  

注意,我们必须将前两个顶点属性的stride参数调整为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有一个内置的纹理对象数据类型,称为采样器,它以我们想要的纹理类型作为后缀,例如sampler1D, sampler3D或在我们的情况下sampler2D。然后,我们可以通过简单地声明一个统一的sampler2D来为片段着色器添加纹理,我们稍后将纹理分配给它。

#version 330 core
out vec4 FragColor;
  
in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D ourTexture;

void main()
{
    FragColor = texture(ourTexture, TexCoord);
}

为了对纹理的颜色进行采样,我们使用GLSL的内置纹理函数,该函数的第一个参数是纹理采样器,第二个参数是相应的纹理坐标。然后纹理函数使用我们之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出是纹理在(插值)纹理坐标上的(过滤)颜色。

现在剩下要做的就是在调用glDrawElements之前绑定纹理,然后它会自动将纹理分配给片段着色器的采样器:

glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

之后你就可以看到成品了。

在这里插入图片描述

当然,如果你发现出了问题也没有关系,我们已经把涉及的所有文件统统打包好了,你可以在这个链接找到。vs2022+OpenGL纹理的使用源码资源-优快云文库

更进一步

了得到一点时髦,我们还可以将结果纹理颜色与顶点颜色混合。我们只需将生成的纹理颜色与片段着色器中的顶点颜色相乘,混合两种颜色:

FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0); 

结果应该是顶点颜色和纹理颜色的混合:

在这里插入图片描述

纹理单元

你可能想知道为什么sampler2D变量是统一的,如果我们甚至没有用glUniform赋值给它。使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样我们就可以在一个片段着色器中一次设置多个纹理。纹理的这个位置通常被称为纹理单元。纹理的默认纹理单位是0,这是默认的活动纹理单位,所以我们不需要在前一节中分配位置;注意,并不是所有的图形驱动程序都分配一个默认的纹理单元,所以前面的部分可能没有为您呈现。

纹理单元的主要目的是允许我们在着色器中使用多个纹理。通过将纹理单元分配给采样器,我们可以一次绑定多个纹理,只要我们首先激活相应的纹理单元。就像glBindTexture一样,我们可以使用glActiveTexture来激活我们想要使用的纹理单元:

glActiveTexture(GL_TEXTURE0); // activate the texture unit first before binding texture
glBindTexture(GL_TEXTURE_2D, texture);

激活纹理单元后,随后的glBindTexture调用将把该纹理绑定到当前活动的纹理单元。纹理单元GL_TEXTURE0总是默认激活的,所以在前面的例子中,当使用glBindTexture时,我们不需要激活任何纹理单元。

OpenGL应该至少有16个纹理单元供你使用,你可以使用GL_TEXTURE0到GL_TEXTURE15来激活它们。它们是按顺序定义的,所以我们也可以通过GL_TEXTURE0 + 8获得GL_TEXTURE8,这在我们必须循环多个纹理单元时很有用。

然而,我们仍然需要编辑片段着色器以接受另一个采样器。这应该是相对简单的:

#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生成最终纹理。对于第二个纹理,我们将使用这个面部表情图像:

在这里插入图片描述

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

我们还必须通过使用glUniform1i设置每个采样器来告诉OpenGL每个着色器采样器属于哪个纹理单元。我们只需要设置一次,所以我们可以在进入渲染循环之前这样做:

ourShader.use(); // don't forget to activate the shader before setting uniforms!  
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // set it manually
ourShader.setInt("texture2", 1); // or with shader class
  
while(...) 
{
    [...]
}

通过glUniform1i设置采样器,我们确保每个均匀采样器对应于适当的纹理单元。您应该得到以下结果:

在这里插入图片描述

你可能注意到纹理是颠倒的!发生这种情况是因为OpenGL期望y轴上的0.0坐标位于图像的底部,但图像通常在y轴的顶部有0.0。幸运的是,stb_image.h可以通过在加载任何图像之前添加以下语句来在图像加载期间翻转y轴:

stbi_set_flip_vertically_on_load(true);  

告诉stb_image.h在加载图像时翻转y轴后,你应该得到以下结果:

在这里插入图片描述

很好,今天的内容就到这里了,源代码在我的博客里面都可以找到,谢谢大家!创作不易,还希望大家多多点赞收藏。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

金沙阳

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值