OpenGL核心技术之GPU编程

笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者,国家专利发明人;已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。

CSDN视频网址:http://edu.csdn.net/lecturer/144

3D游戏引擎的核心是渲染,游戏品质的提升需要通过Shader编程实现渲染技术,通常的渲染方式一般会通过Direct3D或者是OpenGL,对于目前比较流行的引擎Unity3D,Cocos2d-x,UE4引擎在移动端的渲染都是采用的OpenGL,所以掌握OpenGL的渲染非常重要,这有助于我们了解引擎内部的实现方式。

对于Shader脚本,实现方式主要分为顶点着色器和片段着色器,顶点着色器计算得到的值是传递给片段着色器使用的,下面就详细介绍Shader编程的核心内容。

每次我们打算从顶点向片段着色器发送数据,我们都会声明一个相互匹配的输出/输入变量。从一个着色器向另一个着色器发送数据,一次将它们声明好是最简单的方式,但是随着应用变得越来越大,你也许会打算发送的不仅仅是变量,最好还可以包括数组和结构体。

为了帮助我们组织这些变量,GLSL为我们提供了一些叫做接口块(Interface Blocks)的东西,好让我们能够组织这些变量。声明接口块和声明struct有点像,不同之处是它现在基于块(block),使用in和out关键字来声明,最后它将成为一个输入或输出块(block)。

#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec2 texCoords;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out VS_OUT
{
    vec2 TexCoords;
} vs_out;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0f);
    vs_out.TexCoords = texCoords;
}
这次我们声明一个叫做vs_out的接口块,它把我们需要发送给下个阶段着色器的所有输出变量组合起来。虽然这是一个微不足道的例子,
但是你可以想象一下,它的确能够帮助我们组织着色器的输入和输出。

然后,我们还需要在下一个着色器——片段着色器中声明一个输入interface block。块名(block name)应该是一样的,但是实例名可以是任意的。

#version 330 core
out vec4 color;

in VS_OUT
{
    vec2 TexCoords;
} fs_in;

uniform sampler2D texture;

void main()
{
    color = texture(texture, fs_in.TexCoords);
}

如果两个interface block名一致,它们对应的输入和输出就会匹配起来。这是另一个可以帮助我们组织代码的有用功能,特别是在跨着色阶段的情况,比如几何着色器。

如果大家使用OpenGL很长时间了,也学到了一些很酷的技巧,但是产生了一些烦恼。比如说,当时用一个以上的着色器的时候,我们必须一次次设置uniform变量,尽管对于每个着色器来说它们都是一样的,所以为什么还麻烦地多次设置它们呢?

OpenGL为我们提供了一个叫做uniform缓冲对象(Uniform Buffer Object)的工具,使我们能够声明一系列的全局uniform变量, 它们会在几个着色器程序中保持一致。当时用uniform缓冲的对象时相关的uniform只能设置一次。我们仍需为每个着色器手工设置唯一的uniform。创建和配置一个uniform缓冲对象需要费点功夫。

因为uniform缓冲对象是一个缓冲,因此我们可以使用glGenBuffers创建一个,然后绑定到GL_UNIFORM_BUFFER缓冲目标上,然后把所有相关uniform数据存入缓冲。有一些原则,像uniform缓冲对象如何储存数据,我们会在稍后讨论。首先我们我们在一个简单的顶点着色器中,用uniform块(uniform block)储存投影和视图矩阵:

#version 330 core
layout (location = 0) in vec3 position;

layout (std140) uniform Matrices
{
    mat4 projection;
    mat4 view;
};

uniform mat4 model;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0);
}

前面,大多数例子里我们在每次渲染迭代,都为projection和view矩阵设置uniform。这个例子里使用了uniform缓冲对象,这非常有用,因为这些矩阵我们设置一次就行了。

在这里我们声明了一个叫做Matrices的uniform块,它储存两个4×4矩阵。在uniform块中的变量可以直接获取,而不用使用block名作为前缀。接着我们在缓冲中储存这些矩阵的值,每个声明了这个uniform块的着色器都能够获取矩阵。

现在你可能会奇怪layout(std140)是什么意思。它的意思是说当前定义的uniform块为它的内容使用特定的内存布局,这个声明实际上是设置uniform块布局(uniform block layout)。

一个uniform块的内容被储存到一个缓冲对象中,实际上就是在一块内存中。因为这块内存也不清楚它保存着什么类型的数据,我们就必须告诉OpenGL哪一块内存对应着色器中哪一个uniform变量。

假想下面的uniform块在一个着色器中:

layout (std140) uniform ExampleBlock
{
    float value;
    vec3 vector;
    mat4 matrix;
    float values[3];
    bool boolean;
    int integer;
};

我们所希望知道的是每个变量的大小(以字节为单位)和偏移量(从block的起始处),所以我们可以以各自的顺序把它们放进一个缓冲里。每个元素的大小在OpenGL中都很清楚,直接与C++数据类型呼应,向量和矩阵是一个float序列(数组)。OpenGL没有澄清的是变量之间的间距。这让硬件能以它认为合适的位置方式变量。比如有些硬件可以在float旁边放置一个vec3。不是所有硬件都能这样做,在vec3旁边附加一个float之前,给vec3加一个边距使之成为4个(空间连续的)float数组。功能很好,但对于我们来说用起来不方便。

GLSL 默认使用的uniform内存布局叫做共享布局(shared layout),叫共享是因为一旦偏移量被硬件定义,它们就会持续地被多个程序所共享。使用共享布局,GLSL可以为了优化而重新放置uniform变量,只要变量的顺序保持完整。因为我们不知道每个uniform变量的偏移量是多少,所以我们也就不知道如何精确地填充uniform缓冲。我们可以使用像glGetUniformIndices这样的函数来查询这个信息,但是这超出了本节教程的范围。

由于共享布局给我们做了一些空间优化。通常在实践中并不适用分享布局,而是使用std140布局。std140通过一系列的规则的规范声明了它们各自的偏移量,std140布局为每个变量类型显式地声明了内存的布局。由于被显式的提及,我们就可以手工算出每个变量的偏移量。

每个变量都有一个基线对齐(base alignment),它等于在一个uniform块中这个变量所占的空间(包含边距),这个基线对齐是使用std140布局原则计算出来的。然后,我们为每个变量计算出它的对齐偏移(aligned offset),这是一个变量从块(block)开始处的字节偏移量。变量对齐的字节偏移一定等于它的基线对齐的倍数。

准确的布局规则可以在OpenGL的uniform缓冲规范中得到,但我们会列出最常见的规范。GLSL中每个变量类型比如int、float和bool被定义为4字节,每4字节被表示为N。

类型布局规范
像int和bool这样的标量每个标量的基线为N
向量每个向量的基线是2N或4N大小。这意味着vec3的基线为4N
标量与向量数组每个元素的基线与vec4的相同
矩阵被看做是存储着大量向量的数组,每个元素的基数与vec4相同
结构体根据以上规则计算其各个元素,并且间距必须是vec4基线的倍数
像OpenGL大多数规范一样,举个例子就很容易理解。再次利用之前介绍的uniform块 ExampleBlock ,我们用std140布局,计算它的每个成员的aligned offset(对齐偏移):

layout (std140) uniform ExampleBlock
{
                     // base alignment ----------  // aligned offset
    float value;     // 4                          // 0
    vec3 vector;     // 16                         // 16 (必须是16的倍数,因此 4->16)
    mat4 matrix;     // 16                         // 32  (第 0 行)
                     // 16                         // 48  (第 1 行)
                     // 16                         // 64  (第 2 行)
                     // 16                         // 80  (第 3 行)
    float values[3]; // 16 (数组中的标量与vec4相同)//96 (values[0])
                     // 16                        // 112 (values[1])
                     // 16                        // 128 (values[2])
    bool boolean;    // 4                         // 144
    int integer;     // 4                         // 148
};

尝试自己计算出偏移量,把它们和表格对比,你可以把这件事当作一个练习。使用计算出来的偏移量,根据std140布局规则,我们可以用glBufferSubData这样的函数,使用变量数据填充缓冲。虽然不是很高效,但std140布局可以保证在每个程序中声明的这个uniform块的布局保持一致。

在定义uniform块前面添加layout (std140)声明,我们就能告诉OpenGL这个uniform块使用了std140布局。另外还有两种其他的布局可以选择,它们需要我们在填充缓冲之前查询每个偏移量。我们已经了解了分享布局(shared layout)和其他的布局都将被封装(packed)。当使用封装(packed)布局的时候,不能保证布局在别的程序中能够保持一致,因为它允许编译器从uniform块中优化出去uniform变量,这在每个着色器中都可能不同。

我们讨论了uniform块在着色器中的定义和如何定义它们的内存布局,但是我们还没有讨论如何使用它们。

首先我们需要创建一个uniform缓冲对象,这要使用glGenBuffers来完成。当我们拥有了一个缓冲对象,我们就把它绑定到GL_UNIFORM_BUFFER目标上,调用glBufferData来给它分配足够的空间。

GLuint uboExampleBlock;
glGenBuffers(1, &uboExampleBlock);
glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
glBufferData(GL_UNIFORM_BUFFER, 150, NULL, GL_STATIC_DRAW); // 分配150个字节的内存空间
glBindBuffer(GL_UNIFORM_BUFFER, 0);

现在任何时候当我们打算往缓冲中更新或插入数据,我们就绑定到uboExampleBlock上,并使用glBufferSubData来更新它的内存。我们只需要更新这个uniform缓冲一次,所有的使用这个缓冲着色器就都会使用它更新的数据了。但是,OpenGL是如何知道哪个uniform缓冲对应哪个uniform块呢?

在OpenGL环境(context)中,定义了若干绑定点(binding points),在哪儿我们可以把一个uniform缓冲链接上去。当我们创建了一个uniform缓冲,我们把它链接到一个这个绑定点上,我们也把着色器中uniform块链接到同一个绑定点上,这样就把它们链接到一起了。下面的图标表示了这点:



你可以看到,我们可以将多个uniform缓冲绑定到不同绑定点上。因为着色器A和着色器B都有一个链接到同一个绑定点0的uniform块,它们的uniform块分享同样的uniform数据—uboMatrices有一个前提条件是两个着色器必须都定义了Matrices这个uniform块。

我们调用glUniformBlockBinding函数来把uniform块设置到一个特定的绑定点上。函数的第一个参数是一个程序对象,接着是一个uniform块索引(uniform block index)和打算链接的绑定点。uniform块索引是一个着色器中定义的uniform块的索引位置,可以调用glGetUniformBlockIndex来获取这个值,这个函数接收一个程序对象和uniform块的名字。我们可以从图表设置Lights这个uniform块链接到绑定点2:

GLuint lights_index = glGetUniformBlockIndex(shaderA.Program, "Lights");
glUniformBlockBinding(shaderA.Program, lights_index, 2);

注意,我们必须在每个着色器中重复做这件事。

从OpenGL4.2起,也可以在着色器中通过添加另一个布局标识符来储存一个uniform块的绑定点,就不用我们调用glGetUniformBlockIndexglUniformBlockBinding了。下面的代表显式设置了Lights这个uniform块的绑定点:

layout(std140, binding = 2) uniform Lights { ... };

然后我们还需要把uniform缓冲对象绑定到同样的绑定点上,这个可以使用 glBindBufferBase glBindBufferRange 来完成。

glBindBufferBase(GL_UNIFORM_BUFFER, 2, uboExampleBlock);
// 或者
glBindBufferRange(GL_UNIFORM_BUFFER, 2, uboExampleBlock, 0, 150);

函数glBindBufferBase接收一个目标、一个绑定点索引和一个uniform缓冲对象作为它的参数。这个函数把uboExampleBlock链接到绑定点2上面,自此绑定点所链接的两端都链接在一起了。你还可以使用glBindBufferRange函数,这个函数还需要一个偏移量和大小作为参数,这样你就可以只把一定范围的uniform缓冲绑定到一个绑定点上了。使用glBindBufferRage函数,你能够将多个不同的uniform块链接到同一个uniform缓冲对象上。

现在所有事情都做好了,我们可以开始向uniform缓冲添加数据了。我们可以使用glBufferSubData将所有数据添加为一个单独的字节数组或者更新缓冲的部分内容,只要我们愿意。为了更新uniform变量boolean,我们可以这样更新uniform缓冲对象:

glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
GLint b = true; // GLSL中的布尔值是4个字节,因此我们将它创建为一个4字节的整数
glBufferSubData(GL_UNIFORM_BUFFER, 142, 4, &b);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
同样的处理也能够应用到uniform块中其他uniform变量上。

下面通过一个简单的例子给读者介绍一下:

使用uniform缓冲对象的例子。如果我们回头看看前面所有演示的代码,我们一直使用了3个矩阵:投影、视图和模型矩阵。所有这些矩阵中,只有模型矩阵是频繁变化的。如果我们有多个着色器使用了这些矩阵,我们可能最好还是使用uniform缓冲对象。

我们将把投影和视图矩阵储存到一个uniform块中,它被取名为Matrices。我们不打算储存模型矩阵,因为模型矩阵会频繁在着色器间更改,所以使用uniform缓冲对象真的不会带来什么好处。

#version 330 core
layout (location = 0) in vec3 position;

layout (std140) uniform Matrices
{
    mat4 projection;
    mat4 view;
};
uniform mat4 model;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0);
}

这儿没什么特别的,除了我们现在使用了一个带有std140布局的uniform块。我们在例程中将显示4个立方体,每个立方体都使用一个不同的着色器程序。4个着色器程序使用同样的顶点着色器,但是它们将使用各自的片段着色器,每个片段着色器输出一个单色。

首先,我们把顶点着色器的uniform块设置为绑定点0。注意,我们必须为每个着色器做这件事。

GLuint uniformBlockIndexRed = glGetUniformBlockIndex(shaderRed.Program, "Matrices");
GLuint uniformBlockIndexGreen = glGetUniformBlockIndex(shaderGreen.Program, "Matrices");
GLuint uniformBlockIndexBlue = glGetUniformBlockIndex(shaderBlue.Program, "Matrices");
GLuint uniformBlockIndexYellow = glGetUniformBlockIndex(shaderYellow.Program, "Matrices");  

glUniformBlockBinding(shaderRed.Program, uniformBlockIndexRed, 0);
glUniformBlockBinding(shaderGreen.Program, uniformBlockIndexGreen, 0);
glUniformBlockBinding(shaderBlue.Program, uniformBlockIndexBlue, 0);
glUniformBlockBinding(shaderYellow.Program, uniformBlockIndexYellow, 0);
然后,我们创建真正的uniform缓冲对象,并把缓冲绑定到绑定点0:

GLuint uboMatrices
glGenBuffers(1, &uboMatrices);

glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), NULL, GL_STATIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

glBindBufferRange(GL_UNIFORM_BUFFER, 0, uboMatrices, 0, 2 * sizeof(glm::mat4));

我们先为缓冲分配足够的内存,它等于glm::mat4的2倍。GLM的矩阵类型的大小直接对应于GLSL的mat4。然后我们把一个特定范围的缓冲链接到绑定点0,这个例子中应该是整个缓冲。

现在所有要做的事只剩下填充缓冲了。如果我们把视野( field of view)值保持为恒定的投影矩阵(这样就不会有摄像机缩放),我们只要在程序中定义它一次就行了,这也意味着我们只需向缓冲中把它插入一次。因为我们已经在缓冲对象中分配了足够的内存,我们可以在我们进入游戏循环之前使用glBufferSubData来储存投影矩阵:

glm::mat4 projection = glm::perspective(45.0f, (float)width/(float)height, 0.1f, 100.0f);
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), glm::value_ptr(projection));
glBindBuffer(GL_UNIFORM_BUFFER, 0);
这里我们用投影矩阵储存了uniform缓冲的前半部分。在我们在每次渲染迭代绘制物体前,我们用视图矩阵更新缓冲的第二个部分:
glm::mat4 view = camera.GetViewMatrix();
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(
  GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), glm::value_ptr(view));
glBindBuffer(GL_UNIFORM_BUFFER, 0);

这就是uniform缓冲对象。每个包含着 Matrices 这个uniform块的顶点着色器都将对应 uboMatrices 所储存的数据。所以如果我们现在使用4个不同的着色器绘制4个立方体,它们的投影和视图矩阵都是一样的:

glBindVertexArray(cubeVAO);
shaderRed.Use();
glm::mat4 model;
model = glm::translate(model, glm::vec3(-0.75f, 0.75f, 0.0f)); // 移动到左上方
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 36);
// ... 绘制绿色立方体
// ... 绘制蓝色立方体
// ... 绘制黄色立方体
glBindVertexArray(0);
我们只需要在去设置一个 model 的uniform即可。在一个像这样的场景中使用uniform缓冲对象在每个着色器中可以减少uniform的调用。最后效果看起来像这样:


通过改变模型矩阵,每个立方体都移动到窗口的一边,由于片段着色器不同,物体的颜色也不同。这是一个相对简单的场景,我们可以使用uniform缓冲对象,但是任何大型渲染程序有成百上千的活动着色程序,彼时uniform缓冲对象就会闪闪发光了。现把核心代码给读者展示一下:

// Setup cube VAO
    GLuint cubeVAO, cubeVBO;
    glGenVertexArrays(1, &cubeVAO);
    glGenBuffers(1, &cubeVBO);
    glBindVertexArray(cubeVAO);
    glBindBuffer(GL_ARRAY_BUFFER, cubeVBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(cubeVertices), &cubeVertices, GL_STATIC_DRAW);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0); 
    glBindVertexArray(0);

    #pragma endregion

    // Create a uniform buffer object
    // First. We get the relevant block indices
    GLuint uniformBlockIndexRed = glGetUniformBlockIndex(shaderRed.Program, "Matrices");
    GLuint uniformBlockIndexGreen = glGetUniformBlockIndex(shaderGreen.Program, "Matrices");
    GLuint uniformBlockIndexBlue = glGetUniformBlockIndex(shaderBlue.Program, "Matrices");
    GLuint uniformBlockIndexYellow = glGetUniformBlockIndex(shaderYellow.Program, "Matrices");
    // Then we link each shader's uniform block to this uniform binding point
    glUniformBlockBinding(shaderRed.Program, uniformBlockIndexRed, 0);
    glUniformBlockBinding(shaderGreen.Program, uniformBlockIndexGreen, 0);
    glUniformBlockBinding(shaderBlue.Program, uniformBlockIndexBlue, 0);
    glUniformBlockBinding(shaderYellow.Program, uniformBlockIndexYellow, 0);
    // Now actually create the buffer
    GLuint uboMatrices;
    glGenBuffers(1, &uboMatrices);
    glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
    glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), NULL, GL_STATIC_DRAW);
    glBindBuffer(GL_UNIFORM_BUFFER, 0);
    // Define the range of the buffer that links to a uniform binding point
    glBindBufferRange(GL_UNIFORM_BUFFER, 0, uboMatrices, 0, 2 * sizeof(glm::mat4));

    // Store the projection matrix (we only have to do this once) (note: we're not using zoom anymore by changing the FoV. We only create the projection matrix once now)
    glm::mat4 projection = glm::perspective(45.0f, (float)screenWidth/(float)screenHeight, 0.1f, 100.0f);
    glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
    glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), glm::value_ptr(projection));
    glBindBuffer(GL_UNIFORM_BUFFER, 0);

绘制上图中展示的四个立方体核心代码如下所示:

 // Clear buffers
        glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        // Set the view and projection matrix in the uniform block - we only have to do this once per loop iteration.
        glm::mat4 view = camera.GetViewMatrix();	       
        glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
        glBufferSubData(GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), glm::value_ptr(view));
        glBindBuffer(GL_UNIFORM_BUFFER, 0);

        // Draw 4 cubes 
        // RED
        glBindVertexArray(cubeVAO);
        shaderRed.Use();
        glm::mat4 model;
        model = glm::translate(model, glm::vec3(-0.75f, 0.75f, 0.0f));	// Move top-left
        glUniformMatrix4fv(glGetUniformLocation(shaderRed.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
        glDrawArrays(GL_TRIANGLES, 0, 36);        
        // GREEN
        shaderGreen.Use();
        model = glm::mat4();
        model = glm::translate(model, glm::vec3(0.75f, 0.75f, 0.0f));		// Move top-right
        glUniformMatrix4fv(glGetUniformLocation(shaderGreen.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
        glDrawArrays(GL_TRIANGLES, 0, 36);        
        // BLUE
        shaderBlue.Use();
        model = glm::mat4();
        model = glm::translate(model, glm::vec3(-0.75f, -0.75f, 0.0f));	// Move bottom-left
        glUniformMatrix4fv(glGetUniformLocation(shaderBlue.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
        glDrawArrays(GL_TRIANGLES, 0, 36);        
        // YELLOW
        shaderYellow.Use();
        model = glm::mat4();
        model = glm::translate(model, glm::vec3(0.75f, -0.75f, 0.0f));	// Move bottom-right
        glUniformMatrix4fv(glGetUniformLocation(shaderYellow.Program, "model"), 1, GL_FALSE, glm::value_ptr(model));
        glDrawArrays(GL_TRIANGLES, 0, 36);        
        glBindVertexArray(0);		

以上就是关于Shader的技术讲解,掌握一门技术要了解的知识很多的,在这里只是给读者介绍了Shader编写的一些原理,希望对读者有所帮助。。。。。

  • 5
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

海洋_

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

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

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

打赏作者

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

抵扣说明:

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

余额充值