Android OpenGL绘制圆柱体

在之前矩形的项目中,矩形现在被放置在一个更好的角度,并且通过使用纹理,它的外观变得更加吸引人。但是,目前我们的矩形太单调了,这显然是不够的。许多应用程序通过合并简单的图形来构建更复杂的物体,我们即将学习如何进行这样的操作,本篇我们将学习如何构建立体图形,我们先从圆柱体开始。

在这里,我们还需要一个简便的方法来在场景中进行平移、旋转和移动。许多三维应用程序通过使用视图矩阵来实现这些功能;对视图矩阵所做的更改将影响整个场景,就像我们通过一个移动的相机观察事物一样。我们将添加一个视图矩阵,以使旋转和移动变得更加容易。

在本篇的开发计划中,我们将执行以下步骤:

  • 首先,我们将学习如何将三角形组织成三角形带和三角形扇,并将它们合并成一个单一的物体(圆柱体)。
  • 我们还将学习如何定义视图矩阵,并将其集成到我们的矩阵层次结构中。

完成这些后,我们不仅为我们的场景构建了一个圆柱体,而且只需一行代码就能移动整个场景。

构建圆柱体

1.如何构建圆柱体

要创建一个圆柱体,我们需要对圆柱体进行拆分,圆柱体可以被拆分为一个圆形的顶部和一个圆筒形的侧面。顶部的圆形我们可以通过三角形扇来实现,而在OpenGL中为了构建圆柱的侧面,我们可以使用三角形带(Triangle Strip)的概念。三角形带允许我们通过定义一系列的顶点来构建多个三角形,而无需重复定义共用的顶点。通过这种方式,我们可以高效地构建出圆柱体的侧面。

在OpenGL中,三角形扇(Triangle Fan)和三角形带是两种非常有用的技术,它们可以帮助我们以较少的顶点定义复杂的几何形状。例如,通过使用三角形扇,我们可以用六个顶点和四个三角形来构建一个矩形。三角形扇通过围绕一个中心点排列顶点来构建形状,而三角形带则像桥梁的横梁一样排列,每个新的三角形都共享前一个三角形的两个顶点。如下图所示:

正如三角形扇一样,一个三角形带的前三个顶点定义了第一个三角形。这之后的每个额外的顶点都定义了另外的一个三角形。为了使用三角形带定义这个圆柱体的侧面,我们只需要把这个带卷绕成一个管子,并确保最后两个顶点与最前面的两个顶点一致。

对Android来说,已经有很多合适的三维库了,从简单的开源封装二libgdx,再到比较高级的商业化框架,比如Unity3D。这些库能帮助你提高生产率,但只有当你对OpenGL、三维渲染,以及底层是如何把这些东西拼在一起的有了基本的理解后才能体会到;否则这些库没有任何意义,你也许感觉像使用黑箱魔法一样。举个例子,如Spring和Hibernate等框架使Java效率更高,但是如果都不知道Java是如何工作的,就开始使用它们,是不是为时尚早。

研究这些库有助于学习如何开发自己的组件。Java3D和jMonkeyEngine是Java桌面版上广泛使用的框架;因为它们的文档很容易从网上得到,它们是很好的学习起点。

2.添加几何图形类

为了制作圆柱体,我们首先需要确定所需的基本形状。可以通过一个三角形扇形作为顶部和一个三角形带作为侧面来构建。为了简化圆柱体的构建过程,我们计划创建一个名为“Geometry”的几何图形类,这个类将包含一些基本图形的定义,并且我们将使用一个名为ObjectBuilder的工具来实际构建这些形状。

接下来,我们将从创建几何物体类开始。在类内部,我们将添加必要的代码来定义和构建所需的几何形状。这个类将作为构建圆柱体的基础,使得整个过程更加系统化和高效。

class Point(var x: Float = 0f, var y: Float = 0f, var z: Float = 0f) {
    fun translateY(distance:Float):Point{
        return Point(x, y + distance, z)
    }
}

我们已经添加了一个类用来表示三维场景中的一个点,其中有一个辅助函数用于把这个点沿着y轴平移。我们也需要给圆一个定义;在 Point类后面加入如下代码:

class Circle(var center:Point,var radius:Float)

最后是给圆柱体一个定义:

class Cylinder(var center:Point,var radius:Float,var height:Float)

一个圆柱体就像一个扩展的圆,它有一个中心、一个半径和一个高度。

2.添加圆柱体构建器

我们现在可以开始写圆柱体构建器类了。创建一个名为“ObjectBuilder”的类。在类内部,以下面的代码作为开始:

class ObjectBuilder {
    private val FLOAT_PER_VERTEX = 3
    private var vertexData:FloatArray
    private var offset = 0
    
    private constructor(sizeInVertices:Int){
        vertexData = FloatArray(sizeInVertices*FLOAT_PER_VERTEX)
    }

目前为止,没有什么太复杂的。我们定义了一个常量用来表示一个顶点需要多少浮点数,一个数组用于保存这些顶点,以及一个变量用于记录数组中下一个顶点的位置。这个构造函数基于需要的顶点数量初始化了数组。

我们不久将定义一个静态方法用来生成圆柱体。这个静态方法会使用正确的大小创建一个新的ObjectBuilder实例,调用ObjectBuilder实例中的方法给vertexData添加顶点,并把生成的数据返回给调用者。

对于这个物体构建器应该怎样工作,我们给出了一些需求:

  • 调用者可以决定物体应该有多少个点。点越多,圆柱体看上去就越平滑。
  • 物体将被包含在一个浮点数组中。物体被创建后,调用者将有一个绑定到OpenGL的数组和一个绘制物体的命令。
  • 物体将以调用者指定的位置为中心,并平放在x-z平面上,换句话说,物体的顶部要指向正上方。

我们用一个计算圆柱体顶部顶点数量的方法作为开始:

    private fun sizeOfInCircleVertices(numPoints:Int):Int = 1+(numPoints+1)

一个圆柱体的顶部是一个用三角形扇构造的圆;它有一个顶点在圆心,围着圆的每个点都有一个顶点,并且围着圆的第一个顶点要重复两次才能使圆闭合。下面计算圆柱体侧面顶点的数量:

    private fun sizeOfInOpenCylinderVertices(numPoints:Int):Int = (numPoints+1)*2

一个圆柱体的侧面是一个卷起来的长方形,由一个三角形带构造,围着顶部圆的每个点都需要两个顶点,并且前两个顶点要重复两次才能使这个管闭合。

2.1 实现构建圆柱体

接下来我们就要真正开始构建圆柱体了,继续添加如下代码:

        fun createCylinder(cylinder: Cylinder,numPoints:Int):GeneratedData{
            var size = sizeOfInCircleVertices(numPoints)+ sizeOfInOpenCylinderVertices(numPoints)
            var builder  = ObjectBuilder(size)

            var circleTop = Circle(cylinder.center.translateY(cylinder.height/2),cylinder.radius)

            builder.appendCircle(circleTop,numPoints)
            builder.appendRoundTube(cylinder,numPoints)

            return builder.build()
        }

我们所做的第一件事情是找出需要多少个顶点表示这个圆柱体,然后用那个数量实例化一个新的 ObjectBuilder。一个圆柱体由一个圆柱体的顶部(就是一个圆)和一个圆柱体的侧面构成,因此所有顶点的数量就等于sizeOfCircleInVertices(numPoints)+sizeOfOpenCylinderInVertices(numPoints)。

然后,我们要计算圆柱体的顶部应该放在哪里,并调用 appendCircle()创建它。通过调用appendRoundTube(),我们也生成了圆柱体的侧面,之后通过返回build()的结果返回生成的数据。这些方法都不存在,我们后续会持续完善他们。

我们为什么要把圆柱体的顶部移动 cylinder.height/2f呢?看一下下图:

这个圆柱体垂直方向以center.y为中心,因此在那放置圆柱体的侧面没有问题。然而,圆柱体顶部需要被放在侧面的顶部,要想这样做,我们需要把它向上移动圆柱体整体高度的一半。

2.2 用三角扇形构建圆形

下一步是写代码用一个三角形扇构造这个圆柱体的顶部。我们会把数据写进vertexData,并使用 offset记录我们写到数组的哪个位置了。

创建一个名为appendCircle()的新方法,并加入如下代码:

    private fun appendCircle(circle:Circle,numPoints:Int){
        val startVertex = offset / FLOAT_PER_VERTEX
        val numVertices = sizeOfInCircleVertices(numPoints)
        vertexData[offset++] = circle.center.x
        vertexData[offset++] = circle.center.y
        vertexData[offset++] = circle.center.z

        for(i in 0..numPoints){
            var angleInRadians = (i.toFloat()/numPoints.toFloat())*(Math.PI.toFloat()*2f)
            vertexData[offset++] = circle.center.x + circle.radius * cos(angleInRadians)
            vertexData[offset++] = circle.center.y
            vertexData[offset++] = circle.center.z + circle.radius * sin(angleInRadians)
        }
    }

要构建三角形扇,我们首先在circle.center定义一个圆心顶点,接着,我们围绕圆心的点按扇形展开,并把第一个点绕圆周重复两次考虑在内。我们接下来使用三角函数和单位圆,的概念生成那些点。

为了生成沿一个圆周边的点,我们首先需要一个循环,它的范围涵盖从0到360度的整个圆,或者0到2π弧度。要找到圆周上的一个点的x的位置,我们要调用cos(angle),要找到它的z的位置,我们调用sin(angle);我们用圆的半径缩放这两个位置。

因为这个圆将被平放在x-z平面上,单位圆的y分量就会映射到y的位置。

2.3 为三角形扇添加一个绘图命令

我们还需要告诉OpenGL如何绘制这个圆柱体的顶部。因为一个圆柱体是由两个基本图元构造的,一个构造顶部的三角形扇,另一个构造侧面的三角形带,所以我们需要一种方法,它可以把这些绘画命令合并在一起,以便在后面只需要调用draw()即可。能实现这些的一个方法是把每一个绘画命令都加入一个绘画命令列表(list)中。

我们要创建一个接口(interface)表示单个绘画命令。在ObjectBuilder顶部加入如下代码:

    interface DrawCommand{
        fun draw()
    }

我们也需要一个实例变量用于保存那些整理好的绘画命令。

在vertexData后面加入如下定义:

    var drawList = ArrayList<DrawCommand>()

现在在appendCircle()尾部加入如下代码:

        drawList.add(object : DrawCommand {
            override fun draw() {
                GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN,startVertex,numVertices)
            }
        })

通过这些代码,我们就创建了一个内部类,它调用glDrawArrays(),并且我们还把这个内部类添加进了绘画命令列表。后面要绘制这个圆柱体时,我们只需要执行列表中的每个draw()方法。

2.4 用三角形带构建圆柱体的侧面

让我们继续新增如下代码:

    private fun appendRoundTube(cylinder:Cylinder,numPoints:Int){
        val startVertex = offset / FLOAT_PER_VERTEX
        val numVertices = sizeOfInOpenCylinderVertices(numPoints)
        val yStart:Float = cylinder.center.y - (cylinder.height/2f)
        val yEnd = cylinder.center.y + (cylinder.height/2f)

正如以前一样,我们要计算出起始顶点和顶点数量,以便于在绘画命令中使用它们。我们也要搞清楚这个圆柱体从哪里开始到哪里结束,这些位置应该如下图所示:

加入如下代码生成实际的三角形带:

        for(i in 0..numPoints){
            var angleInRadians = (i.toFloat()/numPoints.toFloat())*(PI.toFloat()*2f)
            var xPosition = cylinder.center.x + cylinder.radius * cos(angleInRadians)
            var zPosition = cylinder.center.z + cylinder.radius * sin(angleInRadians)

            vertexData[offset++] = xPosition
            vertexData[offset++] = yStart
            vertexData[offset++] = zPosition

            vertexData[offset++] = xPosition
            vertexData[offset++] = yEnd
            vertexData[offset++] = zPosition
        }

我们使用了同前面生成圆周顶点一样的算法,只是这次我们为圆周上的每个点生成了两个顶点,一个是圆柱顶部,另一个是圆柱底部。前面两个点的位置重复两次以使这个圆柱体闭合。

加入绘图命令完成这个方法:

        drawList.add(object:DrawCommand{
            override fun draw() {
                GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP,startVertex,numVertices)
            }
        })

我们使用 GL_TRIANGLE_STRIP告诉 OpenGL绘制一个三角形带。

2.5 返回构建数据

要使 createCylinder()工作,我们只需要定义build()方法。我们要使用它返回GeneratedData对象内部生成的数据。我们还没有定义这个类,因此,在 ObjectBuilder加入如下类:

    class GeneratedData{
        var vertexData:FloatArray
        var drawList:ArrayList<DrawCommand>
        constructor(vertexData:FloatArray,drawList:ArrayList<DrawCommand>){
            this.vertexData = vertexData
            this.drawList = drawList
        }
    }

这只是一个holder类,以便我们可以用单个对象返回顶点数据和绘画命令列表。现在我们只需要定义build():

    private fun build():GeneratedData = GeneratedData(vertexData,drawList)

至此我们构建圆柱体就算完成了。

3. 创建圆柱体管理类

既然我们已经有一个圆柱体的构建器了,那么构建后的数据就需要被统一进行管理,因此在这里我们需要通过一个类来调用构建圆柱体,并对构建后的数据进行管理,为此我们再新建一个Cylinder:

class Cylinder {
    var POSITION_COMPONENT_COUNT = 3

    var radius:Float = 0f
    var height:Float = 0f

    private var vertexArray: VertexArray
    private var drawList:ArrayList<ObjectBuilder.DrawCommand>

    constructor(radius:Float,height:Float,numPointsAroundPuck:Int){
        var generatedData = ObjectBuilder.createCylinder(Cylinder(Point(),radius,height),numPointsAroundPuck)
        this.radius = radius
        this.height = height

        vertexArray = VertexArray(generatedData.vertexData)
        drawList = generatedData.drawList
    }

当一个新圆柱体被创建的时候,它会生成那个物体的数据,用vertexArray把顶点存储在一个本地缓冲区中,并把绘画命令列表存储在drawList中。

让我们完成这个类:

    fun bindData(colorProgram: ColorShaderProgram){
        vertexArray.setVertexAttribPointer(0,colorProgram.aPositionLocation,POSITION_COMPONENT_COUNT,0)
    }

    fun draw(){
        drawList.forEach{
            it.draw()
        }
    }

第一个方法bindData()它把顶点数据绑定到着色器程序定义的属性上。第二个方法onDraw()只是遍历ObjectBuilder.createCylinder()创建的显示列表。

更新着色器

我们还需要更新颜色着色器。我们现在生成的顶点是不包含颜色属性的,因此我们不得不把颜色作为一个uniform传递进去。所有的改动里第一件要做的就是给ShaderProgram加入一个新的常量:

    protected val U_COLOR = "u_Color"

下一步是更新ColorShaderProgram。继续移除所有aColorLocation的引用,包括getColorAttributeLocation(),然后加入如下uniform位置的定义:

    var uColorLocation = 0

更新构造函数设置uniform的位置:

uColorLocation = GLES20.glGetUniformLocation(program,U_COLOR)

要完成这些改动, setUniforms()更新如下:

    fun setUniforms(matrix:FloatArray,r:Float,g:Float,b:Float){
        GLES20.glUniformMatrix4fv(uMatrixLocation,1,false,matrix,0)
        GLES20.glUniform4f(uColorLocation,r,g,b,1f)
    }

我们还需要更新实际的着色器。更新simple_vertex_shader.glsl的内容如下代码所示:

uniform mat4 u_Matrix;

attribute vec4 a_Position;

void main(){
    gl_Position = u_Matrix * a_Position;
    gl_PointSize = 10.0;
}

更新 simple_fragment_shader.glsl如下代码所示:

precision mediump float;

uniform vec4 u_Color;

void main(){
   gl_FragColor = u_Color;
}

集成所有变化

本篇最困难的部分已经完成了。我们学习了如何用简单的几何形状构造圆柱体,并更新了着色器用以反映这些变化。所有剩下的就是把这些变化集成到Renderer;同时,我们还要学习通过加入视图矩阵添加一个相机的概念。

那么我们为什么想添加另一个矩阵呢?当我们最早开始项目的时候,根本没使用任何矩阵。我们首先添加了一个正交矩阵调整宽高比,接着切换到一个透视矩阵获得一个三维投影。之后添加了一个模型矩阵开始来回移动物体。视图矩阵实际只是模型矩阵的扩展;它的使用是出于同样的目的,只不过它平等地应用于场景中的每一个物体。

1.简单的矩阵层次结构

让我们花点儿时间复习一下,把一个物体放到屏幕上的三种主要的矩阵类型。

模型矩阵
模型矩阵是用来把物体放在世界空间(world-space)坐标系的。比如,我们可能有圆柱体的模型,它们初始的中心点都在(0,0,0)。没有模型矩阵,这些模型就会卡在那里:如果我们想要移动它们,就不得不自己更新每个模型的每个顶点。如果不想这样做,我们可以使用一个模型矩阵,把那些顶点与这个矩阵相乘来变换它们。如果我们想把圆柱体移动到(5,5),我们只需要准备一个模型矩阵,它会为我们完成的。

视图矩阵
视图矩阵是出于同模型矩阵一样的原因被使用的,但是它平等地影响场景中的每一个物体。因为它影响所有的东西,它在功能上等同于一个相机:来回移动相机,你会从不同的视角看见那些东西。

使用另外一个矩阵的优势是它让我们预先把许多变换处理成单个矩阵。举个例子,想象一下我们要来回旋转一个场景,并把它移动一定量的距离。能实现这些的一种方式是把同样的旋转和平移调用应用于每一个单个的物体。尽管那样可行,但如果只把这些变换存到另外一个矩阵,并把这个矩阵应用于每个物体,会更容易实现。

投影矩阵
最后,说说投影矩阵。这个矩阵帮助创建三维的幻象,通常只有当屏幕变换方位时,它才会变化。

让我们也复习一下一个顶点如何从它原来的位置变换到屏幕的。

vertexmodel
这是模型坐标系中的一个顶点。例如,被包含在矩形顶点内部的位置。

vertexworld
这是在世界空间中用模型矩阵定位过的一个顶点。

vertexeye
这是与我们的眼睛或相机相对的一个顶点。我们使用一个视图矩阵让所有的顶点在世界空间中绕着我们当前的观察位置移动。

vertexclip
这是被投影矩阵处理过的一个顶点。下一步就是做透视除法,正如我们在前面篇节中解释的一样。

vertexndc

这是归一化设备坐标系中的一个顶点。一旦一个顶点落在这个坐标中,OpenGL就会把它映射到视口,你就能从屏幕上看到它了。

这个链条看上去如下所示:

vertexclip=ProjectionMatrix * vertexeye

vertexclip=ProjectionMatrix * ViewMatrix * vertexworld

vertexclip=ProjectionMatrix * ViewMatrix * ModelMatrix * vertexmodel

要得到正确的结果,我们需要应用本篇中的每个矩阵。

2.更新Render类

让我们继续给Render添加一个视图矩阵,同时,我们还要处理新的圆柱体。我们将首先在类的顶部添加一些新的矩阵定义:

    var viewMatrix = FloatArray(16)
    var viewProjectionMatrix = FloatArray(16)
    var modelViewProjectionMatrix = FloatArray(16)

我们把视图矩阵存储到viewMatrix,其他两个矩阵用于保存矩阵乘积的结果。我们还需要定义一个Cylinder对象添加一个定义:

    lateinit var cylinder: Cylinder
    cylinder = Cylinder(0.08f,0.04f,32)

2.1 初始化矩阵

下一步是更新onSurfaceChanged(),并初始化视图矩阵。更新onSurfaceChanged()匹配如下内容:

    override fun onSurfaceChanged(p0: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0,0,width,height)
        Matrix.perspectiveM(projectionMatrix,0,45f,width.toFloat()/height.toFloat(),1f,10f)
        Matrix.setLookAtM(viewMatrix,0,0f,2.2f,2.2f,0f,0f,0f,0f,1f,0f)
    }

这个方法的第一部分相当标准:设置视口,并建立投影矩阵。下一部分是新内容:调用setLookAtM()创建一个特殊类型的视图矩阵。

setLookAtM(float[] rm, int rmOffset, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ)

参数描述
float[] rm这是目标数组。这个矩阵的长度应该至少容纳16个元素,以便它能存储视图矩阵
int rmOffsetsetLookAtM()会把结果从rm的这个偏移值开始存进rm
float eyeX, eyeY, eyeZ这是眼睛所在的位置。场景中的所有东西看起来都像是从这个点观察它们一样
float centerX, centerY, centerZ这是眼睛正在看的地方;这个位置出现在整个场景的中心
float upX, upY, upZ要是我们刚才正在讨论你的眼睛,那么这是你的头指向的地方。upY的值为1 意味着你的头笔直指向上方

我们调用setLookAtM()时,把眼睛(eye)设为(0,1.2,2.2),这意味着眼睛的位置在x-z平面上方1.2个单位,并向后2.2个单位。换句话说,场景中的所有东西都出现在你下面1.2个单位和你前面2.2个单位的地方。把中心(center)设为(0,0,0),意味着你将向下看你前面的原点,并把指向(up)设为(0,1,0),意味着你的头是笔直指向上面的,这个场景不会旋转到任何一边。

2.2更新DrawFrame

在运行程序之前,只剩下最后几个改动了,看看这些新的改动。在 glClear()调用后面把下面的代码加人 onDrawFrame():

 Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)

这会把投影和视图矩阵乘在一起的结果缓存到viewProjectionMatrix。用如下代码替换onDrawFrame()的余下部分:

        positionTableInScene()
        textureProgram.useProgram()
        textureProgram.setUniforms(modelViewProjectionMatrix,texture)
        table.bindData(textureProgram)
        table.draw()

        colorProgram.useProgram()
        positionObjectInScene(0f,cylinder.height/2f,0f)
        colorProgram.setUniforms(modelViewProjectionMatrix,0.8f,0.8f,1f)
        cylinder.bindData(colorProgram)
        cylinder.draw()

这段代码的大部分与上一篇相同,但是有一些关键的区别。第一个不同之处是我们在绘制那些物体之前调用了positionTableInScene()和positionObjectInScene()。我们在绘制圆柱体之前还更新了setUniforms(),并且添加了绘制圆柱体的代码。

让我们添加positionTableInScene()的定义:

    private fun positionTableInScene(){
        Matrix.setIdentityM(modelMatrix,0)
        Matrix.rotateM(modelMatrix,0,-90f,1f,0f,0f)
        Matrix.multiplyMM(modelViewProjectionMatrix,0,viewProjectionMatrix,0,modelMatrix,0)
    }

这个桌子(之前的矩形)原来是以x和y坐标定义的,因此要使它平放在地上,我们需要让它绕x轴向后旋转90度。注意,不像前面的篇节,我们不需要把桌子平移一定的距离,因为我们想要桌子在世界坐标里保持在位置(0,0,0),并且视图矩阵已经想办法使桌子对我们可见了。

最后一步是通过把viewProjectionMatrix和modelMatrix相乘将所有矩阵合并在一起,并把结果存储到modelViewProjectionMatrix,它接着会被传递给着色器程序。

让我们把positionObjectInScene()的定义也添加进来:

    private fun positionObjectInScene(x:Float,y:Float,z:Float){
        Matrix.setIdentityM(modelMatrix,0)
        Matrix.translateM(modelMatrix,0,x,y,z)
        Matrix.multiplyMM(modelViewProjectionMatrix,0,viewProjectionMatrix,0,modelMatrix,0)
    }

圆柱体已经被定义好,并被水平放在x-z平面上了,因此不需要旋转它们。我们要根据传递进来的参数平移它们,将它们放在桌子上方正确的位置。运行这个程序。如果一切按计划顺利进行,我们就会看到我们所设计的效果,这里运行截图就略了…,圆柱体看起来应该是放在桌子中间的。

小结

在本章节中,我们成功地掌握了如何生成三角形带和三角形扇,并将它们组合成具体的圆柱体。我们不仅学习了如何构建这些几何形状,还学会了如何将绘制命令封装起来,使得我们可以方便地将它们绑定到一个单一的命令中,简化了操作过程。

此外,我们还引入了矩阵层次结构的概念,特别是用于相机的视图矩阵和用于在世界空间中定位物体的模型矩阵。这种层次结构的引入,极大地方便了我们对场景的操作和物体的移动,使得整个渲染过程变得更加直观和高效。通过这些学习,我们能够更好地控制和渲染复杂的三维场景。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值