OpenGL学习笔记6-Textures

Textures(纹理)

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

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

在图像旁边,纹理也可以用来存储任意数据发送到着色器的大集合,但我们将留给一个不同的主题。

下面你会看到一幅砖墙的纹理图像 brick wall,它映射到前一章的三角形。

 

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

纹理坐标在x轴和y轴上从0到1(记住我们使用2D纹理图像)。使用纹理坐标检索纹理颜色称为采样。纹理图像左上角的纹理坐标从(0,0)开始到纹理图像右上角的纹理坐标从(1,1)开始。下图展示了我们如何将纹理坐标映射到三角形:

得到的纹理坐标会是这样的:


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提供了更多的选项:

  • GL_REPEAT: 纹理的默认行为。重复纹理图像。
  • GL_MIRRORED_REPEAT: 与GL_REPEAT相同,但在每次重复时镜像图像。
  • GL_CLAMP_TO_EDGE: 将坐标夹在0和1之间。其结果是较高的坐标被夹在边缘上,导致边缘图案被拉伸。
  • GL_CLAMP_TO_BORDER:范围外的坐标现在被赋予一个用户指定的边框颜色。

当使用默认范围之外的纹理坐标时,每个选项都有不同的视觉输出。让我们看看这些看起来像什么样纹理图像(原始图像Holger Rezende):

上述每个选项都可以设置每个坐标轴(s, t(和r,如果你使用3D纹理)相当于x,y,z) glTexParameter*函数:


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(也称为nearest neighbor或point filtering)是OpenGL的默认纹理过滤方法。当设置为GL_NEAREST时,OpenGL选择中心最接近纹理坐标的texel。下面你可以看到4个像素的十字代表了确切的纹理坐标。左上角的texel的中心距离纹理坐标最近,因此选择它作为采样的颜色:

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的概念,它基本上是一个纹理图像的集合,其中每个后续的纹理都是前一个纹理的两倍。mipmaps背后的想法应该很容易理解:在从查看者获得一定的距离阈值后,OpenGL将使用最适合对象距离的不同mipmap纹理。由于对象距离较远,较小的分辨率对用户来说是不明显的。这样OpenGL就能够对正确的texel进行采样,而且在对这部分mipmaps进行采样时,涉及的缓存内存更少。让我们仔细看看mipmapped纹理是什么样子的

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

在渲染过程中在mipmaps级别之间切换时,OpenGL可能会显示一些artifacts ,比如两个mipmap层之间的清晰边缘。就像普通的纹理过滤一样,也可以使用最近过滤和线性过滤来在mipmap级别之间切换。要指定mipmap级别之间的过滤方法,我们可以用以下四个选项之一替换原来的过滤方法:

  • GL_NEAREST_MIPMAP_NEAREST:使用最近的mipmap匹配像素大小,并使用最近邻插值进行纹理采样。
  • GL_LINEAR_MIPMAP_NEAREST:获取最近的mipmap级别,并使用线性插值对该级别进行采样。
  • GL_NEAREST_MIPMAP_LINEAR:在两个最接近像素大小的mipmaps之间进行线性插值,并通过最近邻插值对插值层进行采样。
  • GL_LINEAR_MIPMAP_LINEAR: 在两个最接近的mipmaps之间进行线性插值,并通过线性插值对插值层进行采样。

就像纹理过滤一样,我们可以使用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筛选选项设置为放大筛选。这没有任何效果,因为mipmaps主要用于纹理缩小:纹理放大不使用mipmaps,给它一个mipmap过滤选项会生成OpenGL GL_INVALID_ENUM错误代码。

Loading and creating textures(加载和创建纹理)

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

另一个解决方案(可能是一个不错的方案)是使用一个支持几种流行格式的图像加载库,它可以为我们完成所有困难的工作。像stb_image.h这样的库。

stb_image.h

h是一个非常流行的单头图像加载库,由 Sean Barrett 提供,它能够加载最流行的文件格式,很容易集成到您的项目中。可以从这里here下载stb_image.h。只需下载单个头文件,将其作为stb_image.h添加到您的项目中,然后用下面的代码创建一个额外的c++文件:


#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

通过定义STB_IMAGE_IMPLEMENTATION,预处理器修改头文件,使其只包含相关的定义源代码,有效地将头文件转换为.cpp文件,仅此而已。现在只需在程序的某个地方包含stb_image.h并进行编译。

对于下面的纹理部分,我们将使用一个木制容器wooden container的图像。我们使用它( stb_image.h )的stbi_load函数来加载一个图像:


int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0); 

该函数首先接受图像文件的位置作为输入。然后,它期望您提供三个int作为它的第二个、第三个和第四个参数,stb_image.h将用结果图像的宽度、高度和颜色通道的数量填充这些参数。我们需要图像的宽度和高度,以便稍后生成纹理。

Generating a texture(创建texture)

就像OpenGL中之前的任何对象,纹理都是用ID引用的;让我们创建一个:


unsigned int texture;
glGenTextures(1, &texture);  

glGenTextures函数首先将我们想要生成多少纹理作为输入,并将它们存储在一个无符号int数组中,作为它的第二个参数(在我们的例子中只有一个无符号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);

This is a large function with quite a few parameters so we'll walk through them step-by-step:

这是一个具有相当多参数的大函数,所以我们将一步步地讲解:

  • 第一个参数指定纹理目标;将其设置为GL_TEXTURE_2D意味着此操作将在当前绑定的纹理对象上生成一个纹理(因此绑定到目标GL_TEXTURE_1D或GL_TEXTURE_3D的纹理将不受影响)。
  • 第二个参数指定了我们想要为其创建纹理的mipmap级别,如果您想手动设置每个mipmap级别,但是我们将它保留在基本级别,即0。
  • 第三个参数告诉OpenGL我们想以什么格式存储纹理。我们的图像只有RGB值,所以我们将用RGB值存储纹理。
  • 第4和第5个参数设置产生的纹理的宽度和高度。我们之前在加载图像时存储了这些变量,因此我们将使用相应的变量。
  • 下一个参数应该总是0(一些遗留的东西)。
  • 第7和第8个参数指定源图像的格式和数据类型。我们用RGB值加载图像并将它们存储为char(字节),因此我们将传递相应的值。
  • 最后一个参数是实际的图像数据。

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

After we're done generating the texture and its corresponding mipmaps, it is good practice to free the image memory:

在我们完成纹理及其对应的mipmaps生成后,释放图像内存是一个好做法:


stbi_image_free(data);

The whole process of generating a texture thus looks something like this:

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


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

Applying textures(应用纹理)

在接下来的章节中,我们将使用Hello Triangle一章最后一部分中使用glDrawElements绘制的矩形。我们需要通知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);  

注意,我们必须调整前两个顶点属性的步幅参数为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;
}

然后fragment shader应该接受TexCoord输出变量作为输入变量。

片段着色器也应该有访问纹理对象的权限,但是我们如何将纹理对象传递给片段着色器呢?GLSL有一个内置的纹理对象数据类型叫做采样器,它将我们想要的纹理类型作为后缀,例如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的内置纹理函数,它的第一个参数是一个纹理采样器,第二个参数是相应的纹理坐标。纹理函数然后使用我们之前设置的纹理参数来采样相应的颜色值。这个片段着色器的输出是在(插值的)纹理坐标处的(过滤的)纹理颜色。

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


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

如果你做的每件事都正确,你应该看到以下图像:

如果你的矩形完全是白色或黑色,你很可能在这个过程中犯了错误。检查您的着色器日志,并尝试比较您的代码与应用程序的源代码source code.

如果你的纹理代码不工作或显示为完全黑色,继续阅读,并按照你的方式工作到最后一个应该工作的例子。在一些驱动程序上,它需要分配一个纹理单位给每个采样uniform,这是我们将在本章进一步讨论的东西。

为了显得有点古怪,我们也可以混合产生的纹理颜色和顶点的颜色。在fragment shader中,我们简单地将生成的纹理颜色与顶点颜色相乘,混合两种颜色:


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

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

我想你可以说我们的集装箱喜欢迪斯科

Texture Units(纹理单元)

您可能想知道,如果我们没有使用glUniform为sampler2D变量赋值,为什么它是uniform。使用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生成最终纹理。对于第二个纹理,我们将使用你的面部表情的图像,而学习OpenGL:facial expression while learning 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);
}

Note that we now load a .png image that includes an alpha (transparency) channel. This means we now need to specify that the image data contains an alpha channel as well by using GL_RGBA; otherwise OpenGL will incorrectly interpret the image data.

注意,我们现在加载了一个-png图像,其中包括一个alpha(透明)通道。这意味着我们现在需要指定图像数据包含一个alpha通道以及使用GL_RGBA;否则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轴后,你应该会得到以下结果:

 

如果你看到一个快乐的容器,你就做对了。您可以将其与源代码source code. 进行比较。

Exercises

To get more comfortable with textures it is advised to work through these exercises before continuing.

  • Make sure only the happy face looks in the other/reverse direction by changing the fragment shader: solution.
  • Experiment with the different texture wrapping methods by specifying texture coordinates in the range 0.0f to 2.0f instead of 0.0f to 1.0f. See if you can display 4 smiley faces on a single container image clamped at its edge: solution, result. See if you can experiment with other wrapping methods as well.
  • Try to display only the center pixels of the texture image on the rectangle in such a way that the individual pixels are getting visible by changing the texture coordinates. Try to set the texture filtering method to GL_NEAREST to see the pixels more clearly: solution.
  • Use a uniform variable as the mix function's third parameter to vary the amount the two textures are visible. Use the up and down arrow keys to change how much the container or the smiley face is visible: solution.
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值