OpenGL学习笔记:拾取与选择

在开发OpenGL程序时,一个重要的问题就是互动,假设一个场景里面有很多元素,当用鼠标点击不同元素时,期待作出不同的反应,那么在OpenGL里面,是怎么知道我当前鼠标的位置是哪一个物体呢?


OpenGL有一套机制,叫做Picking, 里面涉及到几个核心概念:

1. selection mode. 选择模式

2. name stack. 名字栈

3. hit record。 命中记录

4. viewing volume。 视角范围


在OpenGL的picking中,选择物体不是选择一个单独的物体,而是选择一片范围内的所有物体。这种设计思路是有点奇怪,但是OpenGL就是这麽设计的。假如鼠标当前的位置是(200,200),普通的应用就是选择在点(200,200)处的物体ID, 但OpenGL不然,它是选择以(200,200)为中心的,比如长宽都为20的这个范围内可见的物体,也就是说,前面一种选择,是以点为选择依据,OpenGL的选择,是以中心为(200,200),长宽各位10的面为依据,好像举着一个画框,只要在这个画框内的物体,都会当作返回结果。


我不是很理解为什么OpenGL要这麽设计,但肯定有它的道理。以上的几个核心概念,就是配合这种设计思路的,不难看出:

1. Viewing volume。就是指用来选择的画框

2. hit record。就是所返回的物体数据

3. name stack。是用来分配并保存物体ID的堆栈

4. selection mode. OpenGL有三种模式,Render mode。就是普通的绘图模式,Select mode。在picking时的选择模式,Feedback mode。不画图,不选择,而是把所有最终的渲染完之后的绘画指令返回给用户。在绘图仪上作画等方面特别有用

当需要相应鼠标选择事件的时候,要首先进入selection mode, 然后才能执行相应的操作步骤


以下是详细的操作步骤:

1. 获取鼠标位置

2. 进入选择模式selection mode

3. 设置画框大小view volume,当然,是根据鼠标位置来的

4. 像往常一样绘制场景

5. 退出选择模式,得到选择结果


进入选择模式:

1. glSelectBuffer,设置选择缓冲区,hit record会被OpenGL保存在里面

2. glRenderMode(GL_SELECT),正式进入选择模式


设置画框大小:

1. gluPickMatrix。设置画框近平面大小

2. gluPerspective。影响画框的容量

实例代码:

glSelectBuffer(BUFSIZE,selectBuf);
glRenderMode(GL_SELECT);

glMatrixMode(GL_PROJECTION); //保存投影矩阵
glPushMatrix();
glLoadIdentity();

glGetIntegerv(GL_VIEWPORT,viewport);
gluPickMatrix(cursorX,viewport[3]-cursorY,5,5,viewport);
gluPerspective(45,ratio,0.1,1000);


注意,上面的代码中在执行gluPickMatrix值钱先保存了投影矩阵,然后再重新创造一个新的供自己使用。原因是gluPickMatrix会操作投影举证,该函数和后续的gluPerspective共同作用,最终会生成一个全新的投影矩阵,该矩阵中的所有物体,都会被视为选中。为了不影响旧有的投影矩阵,因此需要保存先

绘制场景:

绘制场景的时候有一点比较重要,那就是对每个需要绘制的单独元素,起一个名字(ID),这样OpenGL才能在推出选择模式的时候告知调用者,哪个物体被选择了。当然如果非不取,那OpenGL也会返回被选择的物体,只是不带名字。因此绘制场景的步骤一般如下:

1. glInitNames, 初始化name stack。 OpenGL的中对元素名字的操作需要通过一个栈,叫做名字栈,为啥这麽墨迹,不直接指定名字?具体原因是OpenGL总要有个地方去读取名字,而且,一个名字下包括哪些需要绘画的内容?OpenGL怎么知道当前画的这个人头是属于上个人的还是上上个人的?只能通过名字栈的变化才能知道。而且一个元素可以有多个内容,怎么搞?于是乎名字栈就应运而生,OpenGL认为这个东东可以解决这一系列问题

2. glPushName。 创建一个名字。

3. 开始画啊画啊画

4. glPopName。一个元素绘制结束

5. 从步骤2循环到4的一个个元素绘制


实例代码:

	glInitNames();

	glPushName(BODY);
	drawBody();
	glPopName();

	glPushName(HEAD);
	drawHead();
	drawEyes();
	glPopName();

	drawGround();  //不带名字的元素

还有另外一个有用的函数,glLoadName。它的作用是用一个新的名字替代栈顶当前的名字。说白了,就等同于:

glPopName()

glPushName(newName)


OpenGL里面,一个物体还可以有多个名字,这个貌似有点荒唐,确实也比较荒唐,但OpenGL也有它的道理,比如一个元素,它的名字叫“head",那么,如果我想知道这个头是谁的头,怎么搞?OpenGL告诉你压力不大,再给他取一个名字叫”Aka",那么你就知道这是Aka的头。也就是说,我绘制了一个元素,然后给它两个名字,一个叫head, 一个叫aka,那么当你选择了该物体的时候,你就知道了,这个物体是aka的头。


示例代码如下:

glPushName(Aka)

glPushName(Head)

draw()

glPopName(Head)

glPopName(Aka)

于是,draw出来的物体就有两个名字了.



处理选择结果:

处理选择接过之前,要先推出selection模式,然后OpenGl会返回一个数目,被选中物体的数目,跟据该数目,就知道在选择缓冲区里面保存了多少个物体,每个物体都有固定的数据结构,一个个的读取该数据结构,就知道哪些物体被选取了。


示例代码:

void stopPicking() {

	int hits;
	
	// restoring the original projection matrix
	glMatrixMode(GL_PROJECTION);
	glPopMatrix();
	glMatrixMode(GL_MODELVIEW);
	glFlush();
	
	// returning to normal rendering mode
	hits = glRenderMode(GL_RENDER);//得到选中物体的数目
	
	// if there are hits process them
	if (hits != 0)
		processHits(hits,selectBuf);
}

重要的是选择缓冲区中hit record的数据结构,该结构是一个GLuinit的类型,也就说里面所有的数据都是uinit,等等,名字不是也在里面吗,这可是string啊,no, 名字其实不是string,准确的说应该叫ID,普通的1,2,3而已

每一个元素的结构提如下:

1. 该物体的名字的数目。一个物体可以有多个名字,你懂的

2. 该物体被选中区域的最小Z值。我们往往根据这个值得知哪一个才是鼠标点所在的物体

3. 该物体被选中区域的最大Z值。

4. 名字

5. 名字。。


由于物体可以有多个名字,甚至可以木有名字,因此元素的结构不是固定大小的,以下为例:

Hit Record ContentsDescription
0表示这个元素没名字
4.2822e+009最小Z值
4.28436e+009最大Z值
1这个元素有1个名字
4.2732e+009最小Z值
4.27334e+009最大Z值
6名字叫6
2这个元素有2个名字
4.27138e+009最小Z值
4.27155e+009最大Z值
2第一个名字叫2
5第二个名字叫5


所以呢,只需要对着选择缓冲区,一个个的读下去就对了,示例代码:

void processHits2 (GLint hits, GLuint buffer[])
{
   unsigned int i, j;
   GLuint names, *ptr, minZ,*ptrNames, numberOfNames;

   printf ("hits = %d\n", hits);
   ptr = (GLuint *) buffer;
   minZ = 0xffffffff;
   for (i = 0; i < hits; i++) {	
      names = *ptr;
	  ptr++;
	  if (*ptr < minZ) {
		  numberOfNames = names;
		  minZ = *ptr;
		  ptrNames = ptr+2;
	  }
	  
	  ptr += names+2;
	}
  printf ("The closest hit names are ");
  ptr = ptrNames;
  for (j = 0; j < numberOfNames; j++,ptr++) {
     printf ("%d ", *ptr);
  }
  printf ("\n");
   
}

注意:以上所有的概念和函数,都只在selection mode下面有效。如果转到了Render mode,系统怎么处理这些函数呢?很简单,华丽的无视掉。


参考文献:

http://www.lighthouse3d.com/opengl/picking/index.php3?openglway

________________________________________________________________________________________________

OpenGL中采用一种比较复杂方式实现了拾取操作,即选择模式。选择模式一种绘制模式,它基本思想在一次拾取操作时,系统根据拾取操作参数(如鼠标位置)生成一个特定视景体,然后由系统重新绘制场景中所有图元,但这些图元并不会绘制到颜色缓存中,系统跟踪有哪些图元绘制到了这个特定视景体中,并将这些对象 标识符保存到拾取缓冲区数组中。


   
 OpenGL中实现 拾取操作主要包括以下步骤:

    1.设置拾取缓冲区
    拾取时,在特定视景体中绘制每个对象都会产生一个命中消息,命中消息将存放在一个名字堆栈中,这个名字堆栈就是拾取缓冲区。函数:
        void glSelectBuffer(GLsizei n, GLunint *buff);
指定了一个具有n个元素整形数组buffer作为拾取缓冲区。对于每个命中消息,都会在拾取缓冲区数组中添加一条记录,每条记录包含了以下信息:
   
 (1)命中发生时堆栈中名称序号;
   
 (2拾取图元所有顶点最大和最小窗口z坐标。这两个值范围都位于[01]内,他们都乘以232-1,然后四舍五入为最接近无符号整数。
   
 (3)命中发生时堆栈中内容(物体的名字),最下面名称排在最前面。

    2.进入选择模式
    在定义了拾取缓冲区后,需要激活选择模式。选择模式指定采用函数:
        GLint glRenderMode(GLenum mode);
其中,参数mode值可以为GL_RENDER(默认值)、GL_SELECTGL_FEEDBACK,分别指定应用程序处于渲染模式、选择模式和反馈模式。应用程序一直处于当前模式下,直到调用本函数改变为其他模式为止。

    3.名字堆栈操作
    在选择模式下,需要对名字堆栈进行一系列操作,包括初始化、压栈、弹栈以及栈顶元素操作等。
        void glInitNames();//
初始化名字堆栈,其初始状态为空
        void glPushName(GLuint name);//
将一个名字压入堆栈,其中name标识图元一个无符号整数值
        void glLoad Name(GLuint name);//
将名字堆栈栈顶元素替换为name
        void glPopName();//
将栈顶元素弹出

    4.设置合适变换过程
    拾取操作可以通过矩形拾取窗口来实现,我们可以用下面函数调用:
        gluPickMatrix(xPick, yPick, widthPick, heightPick, *vp);
其中参数xPickyPick指定相对于显示区域左下角的拾取窗口中心双精度浮点屏幕坐标值。当使用鼠标进行选择操作时,xPickyPick由鼠标位置确定,但要注意y坐标反转。参数widthPickheightPick指定拾取窗口双精度浮点宽高值。参数vp指定了一个包含当前显示区域坐标位置和尺寸等参数整型数组,该参数可以通过函数glGetIntegerv来获得。这个函数可以设置一个用于拾取操作观察空间。

    5.为每个图元分配名字并绘制
    为了标识图元,在图元绘制过程中需要用一个整型值指定图元名称,并在选择模式下,将这个名字压入到名字堆栈中。为了节省名字堆栈空间,应该在图元绘制完成后,将其名字从堆栈中弹出。

    6.切换回渲染模式
    在选择模式下,所有图元绘制完成后,应该再次调用函数glRenderMode选择渲染模式,在帧缓冲存储器中绘制图元,并返回被选中图元个数。

hits=glRenderMode(GL_RENDER);返回值hits表示The number of hit records transferred to the select buffer(转移到缓冲区中已命中的记录数)。

                glPushName(1);                //1                            

             glColor3f(1.0f,0.0f,0.0f);          //2
           glRectf(60.0f,50.0f,150.0f,150.0f);    //3

表示2,3行绘制的对象在名字堆栈中的名字为1,并且该记录的详细信息记录在缓冲区中。hits=glRenderMode(GL_RENDER);返回的是命中的记录条数。根据条数用循环可以读出缓冲区中每个命中物体的名字,根据名字即可画出来。

7.分析选择缓冲区中数据
    拾取操作完成之后,可以根据选择缓冲区中内容进行分析,以确定拾取的图元。

 

程序3-3 OpenGL实现的拾取操作例子
#include <gl/glut.h>
#include "stdio.h"
const GLint pickSize = 32;
int winWidth = 400, winHeight = 300;

void Initial(void)
{
 glClearColor(1.0f, 1.0f, 1.0f, 1.0f);        
}

void DrawRect(GLenum mode)
{
 if(mode == GL_SELECT) glPushName(1); //
压入堆栈
 glColor3f(1.0f,0.0f,0.0f);
 glRectf(60.0f,50.0f,150.0f,150.0f);
 
if(mode == GL_SELECT) glPushName(2); //
压入堆栈
glColor3f(0.0f,1.0f,0.0f);
glRectf(230.0f,50.0f,330.0f,150.0f);

if(mode == GL_SELECT) glPushName(3); //压入堆栈
glColor3f(0.0f,0.0f,1.0f);
glRectf(140.0f,140.0f,240.0f,240.0f);
}

void ProcessPicks(GLint nPicks, GLuint pickBuffer[])
{
 GLint i;
 GLuint name, *ptr;
printf("
选中数目为%d\n",nPicks);
ptr=pickBuffer;
for(i=0;i<nPicks; i++){
 name=*ptr;    //
选中图元在堆栈中位置
 ptr+=3;       //
跳过名字和深度信息
 ptr+=name-1;  //
根据位置信息获得选中图元名字
 if(*ptr==1) printf("
你选择了红色图元\n");
 if(*ptr==2) printf("
你选择了绿色图元\n");
 if(*ptr==3) printf("
你选择了蓝色图元\n");
 ptr++;
}
 printf("\n\n");
}

void ChangeSize(int w, int h)
{
winWidth = w;
winHeight = h;
glViewport(0, 0, w, h);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
gluOrtho2D(0.0,winWidth,0.0,winHeight);
}

void Display(void)
{
glClear(GL_COLOR_BUFFER_BIT);
DrawRect(GL_RENDER);
glFlush();
}

void MousePlot(GLint button, GLint action, GLint xMouse, GLint yMouse)
{
GLuint pickBuffer[pickSize];
GLint nPicks, vp[4];

if(button == GLUT_LEFT_BUTTON && action == GLUT_DOWN){
 glSelectBuffer(pickSize,pickBuffer); //
设置选择缓冲区
glRenderMode(GL_SELECT); //
激活选择模式
glInitNames();   //
初始化名字堆栈
glMatrixMode(GL_PROJECTION);
glPushMatrix();
glLoadIdentity();
glGetIntegerv(GL_VIEWPORT, vp);
//
定义一个10×10选择区域
gluPickMatrix(GLdouble(xMouse), GLdouble(vp[3]-yMouse),10.0,10.0,vp);
gluOrtho2D(0.0,winWidth,0.0,winHeight);
DrawRect(GL_SELECT);
//
恢复投影变换
glMatrixMode(GL_PROJECTION);
glPopMatrix();
glFlush();

//获得选择集并输出
nPicks = glRenderMode(GL_RENDER);
ProcessPicks(nPicks, pickBuffer);
glutPostRedisplay();
}
}

int main(int argc, char* argv[])
{
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB); 
glutInitWindowSize(400,300);                 
glut

___________________________________________________________________________________————————————

以下内容主要整理《OpenGL编程指南》第13章的内容。主要解决以下问题:

 

(1)如何允许用户选择屏幕上的一块区域或者挑选屏幕上所绘制的一个物体?

 

一. 选择

 

1. OpenGL的选择机制如何实现

 

当我们打算使用OpenGL的选择机制时:

(1)首先把整个场景绘制到帧缓冲区中;

(2)然后进入选择模式,并且对场景进行重绘,此时,帧缓冲区的内容将不会被修改;

(3)退出选择模式时,OpenGL就会返回与视景体相交的图元列表,与视景体相交的每一个图元都产生一个所谓的“选择点击”。

 (图元列表:实际上是以点击记录的形式返回,包含了图元的名称以及相关的数据。我们可以访问该列表并处理其中的内容)

 

2. 基本步骤

 

//在绘制了场景之后,进入以下步骤

 

(1)

#define BUFSIZE 512

GLuint selectBuf[BUFSIZE];

glSelectBuffer( BUFSIZE, selectBuf );   //指定将“图元列表”(点击记录)返回到selectBuf数组中

 

(2)

glRenderMode( GL_SELECT );   //进入选择模式

 

(3)

glInitNames();    // //初始化名字堆栈并压入初始元素

glPushName();

 

(4)

glPushMatrix();   //为重绘设置好投影矩阵,注意,为了不影响绘制模式,要用glPushMatrix和glPopMatrix

glMatrixMode(GL_PROJECTION);

glLoadIdentity();

glOrtho(0.0,5.0,0.0,5.0,0.0,10.0);

glMatrixMode(GL_MODELVIEW);

glLoadIdentity();

 

(5)

glLoadName(1);   //将名字堆栈的堆顶设置为1,因此,之后绘制的物体的名字为1

drawTri();

 

glLoadName(2);   //将名字堆栈的堆顶设置为2,因此,之后绘制的物体的名字为2

drawTri();

 

glLoadName(3);   //将名字堆栈的堆顶设置为3,因此,之后绘制的物体的名字为3

drawTri();

drawTri();

 

(6)

glPopMatrix();   //对应glPushMatrix

glFlush();

 

(7)

//返回绘制模式,hits记录的是产生的点击的个数,即在视景体内的图元的个数

Gluint hits = glRenderMode(GL_RENDER); 

 

(8)

//处理点击记录

printf("hits = %d\n", hits); //输出一共产生的点击的个数

 

//注意:

//图元列表(点击记录)selectBuf中记录了所有的点击记录
//某一个点击记录来说,由四个项目组成:
//(1)当点击发生时,名字堆栈中的名称数量

//(2)自上一个点击记录之后,与视景体相交的所有顶点的最小和最大窗口坐标z值
//(3)当点击发生时,名称堆栈的内容,从最底部的元素开始
 

GLuint *ptr = selectBuf;

GLuint names;

for( i=0; i<hits; i++ )

{

    names = *ptr;   //点击发生时,名字堆栈中的名称数量

    printf("names = %d\n",names );

    ptr++;

 

    ptr += 2;  //跳过两个z值

 

    for( j=0; j<names; j++ )

    {

        printf("%d", *ptr);  //输出点击发生时,名字堆栈中所有的名称

        ptr++;

    }

}

 

二. 拾取

 

1. 如何拾取

 

前一节介绍了OpenGL的选择机制如何实现和使用,这一节将深入的介绍如何利用选择模式来确定一个物体是否被挑选。

为了实现这个目的,可以在选择模式中使用一个特殊的挑选矩阵,结合投影矩阵,把绘图限制在视口的一个小区域内,一般是在靠近光标的位置。这样,只有靠近光标位置的物体才会引起点击。

 

gluPickMatrix( GLdouble x, GLdouble y, GLdouble width, GLdouble height, GLint viewport[4] );

(x,y)就是挑选区域的中心的窗口坐标,width和height定义了屏幕坐标下这个挑选区域的大小,viewport是指视口边界,通过glGeIntegerv(GL_VIEWPORT, GLint *viewport)获得

 

(2) 具体实例

 

下面,用OpenGL编程指南上的实例来介绍如何实现拾取。

 

//该程序完成的功能是:绘制9个方块,鼠标左键点击,改变方块的颜色

#include <gl/glut.h> 

int board[3][3];   //存储几个方块的颜色

#define BUFSIZE 512

//处理点击记录:
//hits为产生的点击的数量,buffer中存储点击记录,每个点击记录由四个项目组成
void processHits(GLint hits, GLuint buffer[])
{
 unsigned int i, j;
 GLuint ii, jj, names, *ptr;

 ptr = ( GLuint * )buffer;   

 

 for( i=0; i<hits; i++ )    //处理每一个点击记录
 

 //某一个点击记录来说,由四个项目组成:
//(1)当点击发生时,名字堆栈中的名称数量

//(2)自上一个点击记录之后,与视景体相交的所有顶点的最小和最大窗口坐标z值
//(3)当点击发生时,名称堆栈的内容,从最底部的元素开始

  names = *ptr;      //获得名字堆栈中的名称数量
  ptr += 3;               //跳过前三个记录

  for( j=0; j<names; j++ ) //开始处理名字堆栈中的内容,获取被选中的方块的index
  {
   //对应于绘制方块时,压入名字堆栈中的内容

   if ( j == 0)        //x方向上的index
    ii = *ptr;
   else if( j== 1)  //y方向上的index
    jj = *ptr;
   ptr++;
  }

 }
 board[ii][jj] = (board[ii][jj] + 1) % 3;   //改变被选中方块的颜色

}

 

//绘制所有方块,参数有GL_RENDER和GL_SELECT两种模式
void drawSquares(GLenum mode)
{
 GLuint i,j;

 for(i=0; i<3; i++)
 {
  if( mode == GL_SELECT )       //如果是在选择模式下,将名字堆栈的首元素换成x方向上的索引
   glLoadName(i);

 

  for( j=0; j<3; j++ )
  {
   if( mode == GL_SELECT )    //将y方向上的索引压入名字堆栈
     glPushName(j);

 

   //绘制方块,在GL_SELECT模式下,某一个方块会被选中,因此,会产生一个点击记录

   //该点击被记录时,名字堆栈中有两个名称,分别是i和j的值,也就是被选中方块的索引
   glColor3f( (GLfloat) i / 3.0, (GLfloat) j / 3.0, (GLfloat) board[i][j] / 3.0 );
   glRecti(i,j,i+1,j+1);

  

   if( mode == GL_SELECT ) //弹出名字
     glPopName();
  }
 }
}

 

//当鼠标左键点击窗口时,进入选择模式开始绘制;绘制之后,处理点击记录
void pickSquares(int button, int state, int x, int y)
{
 GLuint selectBuf[BUFSIZE];   //存储点击记录
 GLint hits;                                 //点击记录的个数
 GLint viewport[4];                    //视口

 

 if( button != GLUT_LEFT_BUTTON || state != GLUT_DOWN )
  return;

 

 glGetIntegerv(GL_VIEWPORT, viewport);  //获得视口

 glSelectBuffer( BUFSIZE, selectBuf );    //指定存储点击记录的数组
 glRenderMode( GL_SELECT );          //进入选择模式

 

 glInitNames();           //初始化名字堆栈并压入初始元素
 glPushName(0);

 

 glMatrixMode(GL_PROJECTION);
 glPushMatrix();
 glLoadIdentity();
 
 //设置挑选矩阵,挑选区域的中心坐标是(x,viewport[3]-y),大小是(5,5)
 gluPickMatrix( (GLdouble) x, (GLdouble) ( viewport[3] - y ) , 5.0, 5.0, viewport ); 
 //设置投影矩阵
 gluOrtho2D(0.0, 3.0, 0.0, 3.0 );

 //在选择模式下绘制方块
 drawSquares(GL_SELECT);
 
 glMatrixMode(GL_PROJECTION);
 glPopMatrix();
 glFlush();        //绘制结束

 

//处理点击记录

 hits = glRenderMode(GL_RENDER); //获取记录下的点击的个数
 processHits(hits, selectBuf);           //处理点击记录selectBuf

 glutPostRedisplay();
}

 

void init()
{
 glEnable(GL_DEPTH_TEST);
 glShadeModel(GL_FLAT);
 for( int i=0;i <3; i++ )            //初始化9个方块的颜色
  for( int j=0; j<3; j++ )
   board[i][j] = 0;
}
void display()
{
 glClearColor(0.0,0.0,0.0,0.0);
 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 drawSquares(GL_RENDER);  //基本绘制
 glFlush();
}

void reshape( int w, int h )
{
 glViewport(0,0,w,h);
 glMatrixMode(GL_PROJECTION);
 glLoadIdentity();
 gluOrtho2D( 0.0, 3.0, 0.0, 3.0 );
 glMatrixMode(GL_MODELVIEW);
 glLoadIdentity();
}

int main(int argc, char ** argv)
{
 glutInit(&argc, argv);
 glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB | GLUT_DEPTH);
 glutInitWindowSize(200,200);
 glutInitWindowPosition(100,100);
 glutCreateWindow("pick");
 init();
 glutMouseFunc(pickSquares);   //当鼠标点击时,调用pickSquares,进入选择模式进行绘制
 glutReshapeFunc(reshape);
 glutDisplayFunc(display);      //display只完成基本的绘制
 glutMainLoop();
 return 0;
}




  • 0
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值