OpenGL.ES在Android上的简单实践:6-曲棍球(增加纹理,VAO,ShaderProgram)

OpenGL.ES在Android上的简单实践:6-曲棍球(增加纹理,VAO,ShaderProgram)

 

1、理解纹理

到了第六篇,我们已经设法只用简单的图形和颜色完成了很多工作,但还是缺少很多甲方客户要求的外观细节。此时我们需要加入绘图并加入精致的细节了。下面我们引入纹理(texture)加入额外的细节。例如下图:

简答来说,纹理就是一个图像或者照片,可以被加载到OpenGL中。 OpenGL中的纹理可以用来表示图像、照片、甚至由一个数学算法生成的分形数据。每个二维的纹理都由许多小的纹理元素(texel)组成,它们是小块的数据,类似于我们前面讨论过的片段和像素。要使用纹理,最常用的方式是直接从一个图像文件加载数据。

 

每个二维的纹理都有其自己的坐标空间,其范围是从一个拐角的(0,0)到另外一个拐角的(1, 1)。按照惯例,一个维度叫做S,而另外一个称为T。当我们想要把一个纹理应用于一个三角形或一组三角形的时候,我们要为每个顶点指定一组ST纹理坐标。以便OpenGL知道需要用那个纹理的哪个部分画到每个三角形上。这些纹理坐标有时候也会被称为UV纹理坐标。

 

对于一个OpenGL纹理来说,它没有内在的方向性,因此,我们可以使用不同的坐标把它定向到任何我们喜欢的方向上。大多数计算机系统的图像都有一个默认的方向(如下图左),但是,在Android系统上,原点坐标是在左上角的。(如下图右)我们必须注意这个细节,如果想用正确的方向观看图像,那纹理坐标就必须要考虑这点,这就不会給我们带来麻烦了。

 

2、把纹理加载进OpenGL中

我们现在要把一个图像文件的数据加载到OpenGL的一个纹理中。

作为开始,让我们在utils包里添加一个纹理工具类 TextureHelper,并添加方法loadTexture,代码如下:

public static int loadTexture(Context context,int resourceId) {
        final int[] textureObjectIds = new int[1];
        GLES20.glGenTextures(1, textureObjectIds, 0);

        if(textureObjectIds[0] == 0){
            Log.e(TAG,"Could not generate a new OpenGL texture object!");
            return 0;
        }

        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inScaled = false;   //指定需要的是原始数据,非压缩数据
        final Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), resourceId, options);
        if(bitmap == null){
            Log.e(TAG, "Resource ID "+resourceId + "could not be decode");
            GLES20.glDeleteTextures(1, textureObjectIds, 0);
            return 0;
        }

        //告诉OpenGL后面纹理调用应该是应用于哪个纹理对象
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureObjectIds[0]);

        //设置缩小的时候(GL_TEXTURE_MIN_FILTER)使用mipmap三线程过滤
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR_MIPMAP_LINEAR);
        //设置放大的时候(GL_TEXTURE_MAG_FILTER)使用双线程过滤
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

        bitmap.recycle();

        //快速生成mipmap贴图
        GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D);

        //解除纹理操作的绑定
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

        return textureObjectIds[0];
    }

我们跟着注释来看看这段模板代码:

        1、首先我们创建一个纹理对象用于存储这个纹理图片数据。

        2、下一步,我们使用Android API读入图像文件的数据。OpenGL不能直接读取PNG或者JPEG文件的数据,因为这些文件被编码为特定的压缩格式。OpenGL需要非压缩形式的原始数据,因此,我们需要用Android内置的位图解码器options.inScaled = false指定需要的是原始的、非压缩数据把图像文件解压缩为OpenGL能理解的形式。

        3、然后,我们告诉OpenGL(状态机)绑定操作的是第一步创建的纹理对象,而且我们设置缩小的时候(GL_TEXTURE_MIN_FILTER)使用mipmap三线程过滤;设置放大的时候(GL_TEXTURE_MAG_FILTER)使用双线程过滤;关于纹理过滤,由于篇幅问题,详情请看这里的纹理过滤
        4、跟接着,设置位图数据到纹理中。既然这些数据已经加载进OpenGL了,我们就不需要持有Android的位图数据了,调用bitmap.recycle通知系统及时回收。然后设置OpenGL快速生成MIP贴图,关于什么是MIP贴图,详情请看这里的MIP贴图

        5、最后设置OpenGL(状态机)当前纹理对象为0,其实就是通知OpenGL解绑之前绑定的纹理对象。返回纹理对象的ID

 

 

3、创建新着色器加载纹理

在把纹理绘制到屏幕之前,我们不得不创建一套新的着色器,我们要使得着色器可以接受纹理,并把它应用在要绘制的片段上。这些新的着色器与我们目前为止使用过的着色器相似,只是为了支持纹理增加了一些更新。

我们在res/raw中添加文件,命名为"texture_vertex_shader.glsl",并添加如下代码:

uniform mat4 u_Matrix;

attribute vec4 a_Position;
attribute vec2 a_TextureCoordinates;

varying vec2 v_TextureCoordinates;

void main()
{
    v_TextureCoordinates = a_TextureCoordinates;
    gl_Position = u_Matrix * a_Position;
}

这个着色器的大多数代码看上去应该都比较熟悉:我们已经为矩阵定义了一个uniform,并且也为位置定义了一个attribute。我们使用这些去设置最后的gl_Position。而对于新的东西,我们同样给纹理坐标加了一个新的属性,它叫“a_TextureCoordinates”。因为它有两个分量:S(U)坐标和T(V)坐标,所以被定义为一个vec2。我们把这些坐标传递給顶点着色器被插值的varying,称为v_TextureCoordinates。

 

同样,我们也创建一个叫“texture_fragment_shader.glsl”的新文件,并添加如下代码:

precision mediump float;

uniform sampler2D u_TextureUnit;
varying vec2 v_TextureCoordinates;

void main()
{
    gl_FragColor = texture2D(u_TextureUnit, v_TextureCoordinates);
}

为了把纹理绘制到一个物体上,OpenGL会为每个片段都调用片段着色器,并且每个调用都接收v_TextureCoordiantes的纹理坐标。片段着色器也通过uniform-u_TextureUnit接收实际的纹理数据,u_TextureUnit被定义为一个sampler2D,这个变量类型指的是一个二维纹理数据的数据。

被插值的纹理坐标和纹理数据被传递給着色器函数texture2D,它会读入纹理中那个特定坐标出的颜色值。接着通过把结果赋值給gl_FragColor设置片段的颜色。
 

昨晚一个优雅的程序员,就要写优雅的代码。我们创建一套新的类集合,并把这些现有的桌子数据和着色器程序的代码放到这类里。在运行时需要的时候切换它们。

 

4、封装顶点数据,创建新的类结构

首先我们要理解一点,为什么要封装?每一个物理对象对应着一个具体类,所以我们为桌子创建一个类Table,为木槌创建一个类Mallet。在OpenGL的三维世界,每个具体的对象都需要依赖一定的顶点结构数据VertexArray。这样分析下来类依赖关系就清楚了。大致入下图:

 

我们会创建Mallet类管理木槌的数据,以及Table类管理桌子的数据;并且每个类都会有一个VetexArray类实例,它用来封装存储顶点矩阵的FloatBuffer。

我们将从VertexArray类开始,在项目包中创建一个新的目录,命名为data,并在data下创建VertexArray,添加如下代码:

public class VertexArray {

    private static final int BYTES_PER_FLOAT = 4;

    private final FloatBuffer floatBuffer;

    public VertexArray(float[] vertexData) {
        floatBuffer = ByteBuffer.allocateDirect(vertexData.length * BYTES_PER_FLOAT)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(vertexData);
    }

    public void setVertexAttribPointer(int attributeLocation,
                                       int componentCount, int stride, int dataOffset){
        floatBuffer.position(dataOffset);
        GLES20.glVertexAttribPointer(attributeLocation, componentCount, GLES20.GL_FLOAT,
                false, stride, floatBuffer);
        GLES20.glEnableVertexAttribArray(attributeLocation);
        floatBuffer.position(0);
    }
}

对于Android系统上,float浮点型数据对于4个字节这个是固定的事实,所有我们可以单独出来。

public class Contants {

    public static final int BYTES_PER_FLOAT = 4;
}

这段代码包含一个FloatBuffer,如我们之前解析的,它是用来在本地代码中存储顶点矩阵数据的。这个构建器取用一个java的浮点数据数组,并把它写入这个缓冲区。我们也创建了一个通用的方法把着色器中的属性与这些数据管理起来。最终使能这个顶点数据使能到着色器的对应属性上。  至此我们就完成了OpenGL中一个概念叫Vertex Array Object(顶点数组对象VAO)的封装了。(在OpenGL中很多**O AAO BBO XXO的简称,我们随着文章的深入就会慢慢知道这些O对应具体是什么)

 

 

然后我们可以开始加入桌子,构建一个存储桌子数据的类。我们还会加入纹理坐标,并把这个纹理应用于这个桌子。我们在项目创建objects的目录,并在目录下创建Table类。添加代码如下:

public class Table {
    private static final int POSITION_COMPONENT_COUNT = 2;
    private static final int TEXTURE_COORDINATES_COMPONENT_COUNT = 2;
    private static final int STRIDE = (POSITION_COMPONENT_COUNT + TEXTURE_COORDINATES_COMPONENT_COUNT)
                                        * Constants.BYTES_PER_FLOAT;

    private static final float[] VERTEX_DATA = {
            //x,    y,      s,      t
            0f,     0f,     0.5f,   0.5f,
            -0.5f,  -0.8f,  0f,     0.9f,
            0.5f,   -0.8f,  1f,     0.9f,
            0.5f,   0.8f,   1f,     0.1f,
            -0.5f,  0.8f,   0f,     0.1f,
            -0.5f,  -0.8f,  0f,     0.9f,
    };

    private final VertexArray vertexArray;

    public Table(){
        vertexArray = new VertexArray(VERTEX_DATA);
    }

    public void bindData(TextureShaderProgram shaderProgram){
            vertexArray.setVertexAttributePointer(
                shaderProgram.aPositionLocation,
                POSITION_COMPONENT_COUNT,
                STRIDE,
                0
        );
        vertexArray.setVertexAttributePointer(
                shaderProgram.aTextureCoordinatesLocation,
                TEXTURE_COORDINATES_COMPONENT_COUNT,
                STRIDE,
                POSITION_COMPONENT_COUNT
        );
    }

    public void draw(){
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, 0, 6);
    }
}

我们用下图分析这组顶点数据,纹理上我们恰当的进行了裁剪。看着两个坐标应该就能明白了。详细讲解可以查看这里

 

在同objects目录下创建木槌的类,命名为“Mallet”,添加如下代码:

 

public class Mallet {
    private static final int POSITION_COMPONENT_COUNT = 2;
    private static final int COLOR_COMPONENT_COUNT = 3;
    private static final int STRIDE = (POSITION_COMPONENT_COUNT+COLOR_COMPONENT_COUNT)* Constants.BYTES_PER_FLOAT;

    private static final float[] VERTEX_DATA = {
            // 两个木槌的质点位置
            0f,   -0.4f,  0f,0f,0f,
            0f,    0.4f,  0f,0f,0f,
    };

    private final VertexArray vertexArray;

    public Mallet(){
        vertexArray = new VertexArray(VERTEX_DATA);
    }

    public void bindData(ColorShaderProgram shaderProgram){
            vertexArray.setVertexAttributePointer(
                shaderProgram.aPositionLocation,
                POSITION_COMPONENT_COUNT,
                STRIDE,
                0);

        vertexArray.setVertexAttributePointer(
                shaderProgram.aColorLocation,
                COLOR_COMPONENT_COUNT,
                STRIDE,
                POSITION_COMPONENT_COUNT
        );
    }

    public void draw(){
        GLES20.glDrawArrays(GLES20.GL_POINTS, 0,2);
    }
}

两个类都遵循一样的模式代码,与之前的一样,我们还是把木槌画为点。

顶点数据现在被定义好了:我们有一个类表示桌子,一个类表示木槌,第三个类使得顶点数据更容易管理。剩下的疑问肯定就是Mallet和Table绑定数据(bindData)传入的shaderProgram了。不要紧张,我们下面分析。

 

5、添加 着色器程序 封装类

如图,我们回想一下,在系列文章1~5中编写的HockeyRenderer,画出桌子需要什么? 顶点数据+着色器=draw;上面的步骤,我们把物理对象抽象到一个个顶点数据,那么着色器是不是也应该抽象一个对象呢?一个着色器程序 = 顶点着色器 + 片段着色器,还需要着色器的连接编译等操作,幸好,我们已经把这些操作封装到ShaderHelper里面。下面,我们会为纹理着色器程序创建一个TextureShaderProgram类,用于绘制桌子;为颜色着色器创建另外一个ColorShaderProgram类,用于画木槌的位置点;抽取着色器公共部分称为一个基类ShaderProgram。

 

我们从基类ShaderProgram开始,我们在项目中添加program目录,添加如下代码:

public class ShaderProgram {

    protected final int programId;

    public ShaderProgram(String vertexShaderResourceStr,
                         String fragmentShaderResourceStr){
        programId = ShaderHelper.buildProgram(
                vertexShaderResourceStr,
                fragmentShaderResourceStr);
    }

    public ShaderProgram(Context context, int vertexShaderResourceId,
                         int fragmentShaderResourceId){
        programId = ShaderHelper.buildProgram(
                TextResourceReader.readTextFileFromResource(context,vertexShaderResourceId),
                TextResourceReader.readTextFileFromResource(context,fragmentShaderResourceId));
    }

    public void userProgram() {
     GLES20.glUseProgram(programId);
   }

    public int getShaderProgramId() {
        return programId;
    }

    public void deleteProgram() {
        GLES20.glDeleteProgram(programId);
    }
}

如此简单的代码以致我都没fxxk可说了。不过还是要注意一下构造函数的重载。一个是直接传入shader的源代码字符串,一个是传入放在raw的资源文件ID。注意一下区别。
接下来就是纹理着色器了,我们在program目录下创建TextureShaderProgram的新类,继承自 ShaderProgram,并在该类内部加入代码:

public class TextureShaderProgram extends ShaderProgram {

    protected static final String U_MATRIX = "u_Matrix";
    protected static final String U_TEXTURE_UNIT = "u_TextureUnit";
    public final int uMatrixLocation;
    public final int uTextureUnitLocation;

    protected static final String A_POSITION = "a_Position";
    protected static final String A_TEXTURE_COORDINATES = "a_TextureCoordinates";
    public final int aPositionLocation;
    public final int aTextureCoordinatesLocation;

    public TextureShaderProgram(Context context) {
        super(context, R.raw.texture_vertex_shader, R.raw.texture_fragment_shader);

        uMatrixLocation = GLES20.glGetUniformLocation(programId, U_MATRIX);
        uTextureUnitLocation = GLES20.glGetUniformLocation(programId, U_TEXTURE_UNIT);

        aPositionLocation = GLES20.glGetAttribLocation(programId, A_POSITION);
     aTextureCoordinatesLocation = GLES20.glGetAttribLocation(programId, A_TEXTURE_COORDINATES);
    }

    public void setUniforms(float[] matrix, int textureId) {
        // 传入变化矩阵到shaderProgram
        GLES20.glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0);
        // 激活纹理单元0
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        // 绑定纹理对象ID
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
        // 告诉shaderProgram sampler2D纹理采集器 使用纹理单元0的纹理对象。
        GLES20.glUniform1i(uTextureUnitLocation, 0);
    }
}

我们来看看TextureShaderProgram 的代码,我们在ShaderProgram的基础上加入了四个整型用于保存uniform 和 attribute的位置;调用父类构造函数,编译连接我们选择的资源文件,然后读入并保存那些uniform以及attribute。我们自定义函数setUniforms,第一步就是传递矩阵给u_Matrix这个uniform。下一部分就需要更多的解释了。认真看了:

当我们在OpenGL里使用纹理进行绘制时,我们不需要直接给着色器传递纹理。相反,我们使用纹理单元(texture unit)保存对应纹理,之所以这样做,是因为一个GPU只能同时绘制数量有限的纹理,它使用这些纹理单元表示当前正在被绘制的活动纹理。

如果需要切换纹理,我们可以在纹理单元中来回切换,但是,如果我们切换得太频繁,可能会拖慢渲染的速度,也可以同时用几个纹理单元绘制多个纹理。

通过调用glActiveTexture把纹理单元0(数组坐标第一位,而且使用一定要按顺序,不能跳过0或1,使用后面23的纹理单元)设置为活动纹理单元,我们以此开始,然后通过glBindTexture把纹理绑定到这个单元。接着,通过调用GLES20.glUniform1i(uTextureUnitLocation, 0);把数组坐标0的纹理单元传递給片段着色器中的u_TextureUnit。

 

既然纹理着色器完成了,我们继续在program目录下创建颜色着色器ColorShaderProgram,添加如下代码:

public class ColorShaderProgram extends ShaderProgram {

    protected static final String U_MATRIX = "u_Matrix";
    public final int uMatrixLocation;

    protected static final String A_POSITION = "a_Position";
    protected static final String A_COLOR = "a_Color";
    public final int aPositionLocation;
    public final int aColorLocation;

    public ColorShaderProgram(Context context) {
        super(context, R.raw.simple_vertex_shader, R.raw.simple_fragment_shader);

        uMatrixLocation = GLES20.glGetUniformLocation(programId, U_MATRIX);

        aColorLocation = GLES20.glGetAttribLocation(programId, A_COLOR);
        aPositionLocation = GLES20.glGetAttribLocation(programId, A_POSITION);
    }

    public void setUniforms(float[] matrix){
        GLES20.glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0);
    }

}

通过把这些着色器程序与要绘制的数据进行解耦,就很容易重用这些代码了。比如,我们可以通过这个颜色着色器程序用一个颜色属性绘制任何物体,而不仅仅是木槌的位置点。

 

 

6、绘制纹理

既然我们已经把顶点数据和着色器程序分别放于不同的类中了,现在就可以更新渲染类,使用纹理进行绘制了。我们复制HockeyRenderer -> HockeyRenderer2,删掉所以代码,只保留onSurfaceChanged里面的相关变量和操作,这是我们唯一不会改变的方法。加入如下成员变量和构造函数:

public class HockeyRenderer2 implements GLSurfaceView.Renderer {

    private final Context context;
    private Table table;
    private Mallet mallet;
    private TextureShaderProgram textureShaderProgram;
    private ColorShaderProgram colorShaderProgram;

    int textureId;
    
    public HockeyRenderer2(Context context) {
        this.context = context;
        table = new Table();
        mallet = new Mallet();
    }

    @Override
    public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);

        textureShaderProgram = new TextureShaderProgram(context);
        colorShaderProgram = new ColorShaderProgram(context);

        textureId = TextureHelper.loadTexture(context, R.mipmap.air_hockey_surface);
    }
    ... ...
}

我们保留了上下文和矩阵变量,在构造函数保存context上下文变量,创建table和mallet。在onSurfaceCreated创建纹理着色器和颜色着色器,加载桌子纹理。记住,需要用GLES20接口函数的代码,放在回调接口中。不需要的可以放在其他位置,这下就能明白为什么table和mallet是在构造函数初始化,纹理和着色器初始化就要放在onSurfaceCreated中了。

 

onSurfaceChanged代码保持不变,投影矩阵,模型矩阵照常工作。这里也贴上代码:

    @Override
    public void onSurfaceChanged(GL10 gl10, int width, int height) {
        GLES20.glViewport(0,0,width,height);

        MatrixHelper.perspectiveM(projectionMatrix, 45, (float)width/(float)height, 1f, 100f);

        Matrix.setIdentityM(modelMatrix,0);
        Matrix.translateM(modelMatrix, 0, 0f,0f,-2.5f); //这个距离自己喜欢多大就多大
        Matrix.rotateM(modelMatrix,0,  -30f, 1f,0f,0f); //这个角度也是

        Matrix.multiplyMM(uMatrix,0,  projectionMatrix,0,   modelMatrix,0);
    }

我们继续更新onDrawFrame绘制桌子和木槌位置点:

    @Override
    public void onDrawFrame(GL10 gl10) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

        textureShaderProgram.userProgram();
        textureShaderProgram.setUniforms(uMatrix, textureId);
        table.bindData(textureShaderProgram);
        table.draw();

        colorShaderProgram.userProgram();
        colorShaderProgram.setUniforms(uMatrix);
        mallet.bindData(colorShaderProgram);
        mallet.draw();
    }

绘制桌子的第一件事,我们先调用textureShaderProgram.userProgram告诉OpenGL使用的是哪套着色器程序,然后通过调用setUniforms把变换矩阵 和 纹理传递进去。下一步通过调用bindData把顶点数据和着色器程序绑定起来。最后就是table.draw绘制桌子了。重复同样的调用顺序,用颜色着色器程序绘制木槌的位置点。

 

运行项目看看效果?
 

小结:这次文章内容特别多,我列下知识清单注意点

1、认识纹理,注意Android纹理坐标和传统OpenGL纹理坐标的区别

2、认识VAO顶点数据对象

3、认识物体抽象顶点结构,和着色器抽象。

 

 

 

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
OpenGLVAO(Vertex Array Object)是一种管理顶点数据的对象。它允许我们在渲染过程中定义和绑定顶点属性,从而简化了顶点数据的设置和使用。 使用VAO,我们可以将多个顶点属性(如位置、颜色、法线等)绑定到一个VAO对象中。当我们需要渲染一个物体时,只需绑定对应的VAO,然后使用它所包含的顶点属性进行渲染,这样可以节省大量的重复代码。 在OpenGL中,使用以下步骤来创建和使用VAO: 1. 生成VAO对象:使用glGenVertexArrays函数生成一个VAO对象的ID,例如: ```cpp GLuint vao; glGenVertexArrays(1, &vao); ``` 2. 绑定VAO对象:使用glBindVertexArray函数将VAO对象绑定到当前上下文中,例如: ```cpp glBindVertexArray(vao); ``` 3. 设置顶点属性:使用glVertexAttribPointer函数定义顶点属性的格式和位置,例如: ```cpp // 设置顶点位置属性 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0); glEnableVertexAttribArray(0); // 设置顶点颜色属性 glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, color)); glEnableVertexAttribArray(1); // 设置其他顶点属性... ``` 4. 解绑VAO对象:使用glBindVertexArray函数将VAO对象解绑,例如: ```cpp glBindVertexArray(0); ``` 之后,要渲染使用了VAO的物体,只需简单地绑定对应的VAO对象,然后调用渲染函数即可。 需要注意的是,VAO只保存了对应的顶点属性设置信息,并不保存实际的顶点数据。实际的顶点数据可以保存在一个缓冲区对象(如VBO)中,并通过其他方式与VAO关联。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值