OpenGL播放yuv数据流(着色器SHADER)-windows(一)

OpenGL播放yuv数据流(着色器SHADER)-windows(一)


在写这篇文章之前首先要感谢老雷,http://blog.csdn.net/leixiaohua1020/article/details/40379845这篇文章,可以老雷英年早逝,在此致敬...


下面是代码,具体看注释

//Lvs_OpenGl_Interface.h

/** Copyright (c/c++) <2016.11.22> <zwg/>
* Function  
* Opanal for video rendering related implementation and definition, etc.
* OpanAl 用于视频渲染相关实现及定义,等
*/

#ifndef __LVS_OPENGL_INTERFACE_H__
#define __LVS_OPENGL_INTERFACE_H__

#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
#include <string.h>

//windows
#ifdef WIN32
//opengl库
#include "glew.h"
#include "glut.h"

#pragma comment(lib,"glew32.lib")
//ios
#elif __APPLE__
//opengl库
#include "glew.h"
#include "glut.h"
//ANDROID平台  
#elif __ANDROID__  
//opengl库
#include "glew.h"
#include "glut.h"
//linux
#else
//opengl库
#include "glew.h"
#include "glut.h"
#endif

//到处宏定义
//windows
#ifdef WIN32
#define LVS_DLLEXPORT __declspec(dllexport)
//ios
#elif __APPLE__
#define LVS_DLLEXPORT
//linux
#else
#define LVS_DLLEXPORT
#endif

//着色器用的顶点属性索引 position是由3个(x,y,z)组成,
#define ATTRIB_VERTEX 3
//着色器用的像素,纹理属性索引 而颜色是4个(r,g,b,a)
#define ATTRIB_TEXTURE 4
//是否旋转图像(纹理)
#define TEXTURE_ROTATE    0
//显示图像(纹理)的一半
#define TEXTURE_HALF      0

//窗口消息函数指针
typedef void (*WindowRepaintCK)(void);
//回调读取数据函数指针,数据及时间戳
typedef int (*DisplayDataCK)(void ** data,int * timer_millis);

//接口初始化
int lvs_opengl_interface_init(int screen_width,int screen_height,
	int window_x, int window_y,
	int yuvdata_width,int uvdata_height,
	char * shader_vsh_pathname,char * shader_fsh_pathname,
	DisplayDataCK displaydatack,
	WindowRepaintCK windowrepaintcallback);
//接口释放
void lvs_opengl_interface_uninit();
//接口渲染数据(定时器,渲染时间,毫秒),数据及渲染定时时间在回调里面做处理
void lvs_opengl_interface_write(int value);
//接口opengl消息循环
void lvs_opengl_interface_messageloop();

//渲染数据(定时器,渲染时间,毫秒),数据及渲染定时时间在回调里面做处理
void TimerFunc1(int value);   //这里如果有可能则调成类的成员函数,以后处理,暂时不知道怎么解决类成员函数递归??

using namespace std;

class cclass_opengl_interface;

class cclass_opengl_interface
{
public:
	cclass_opengl_interface();
	virtual ~cclass_opengl_interface();
	//初始化
	int initopengl(int screen_width,int screen_height,
		int window_x, int window_y,
		int yuvdata_width,int uvdata_height,
		char * shader_vsh_pathname,char * shader_fsh_pathname,
		DisplayDataCK displaydatack,
		WindowRepaintCK windowrepaintcallback);
	//初始化着色器,类似于告GPU当传进去数据的时候采用什么样的规则。
	void InitShaders();
	//具体显示图像的函数
	int DisplayImage(void * parm);
	//opengl消息循环
	void messageloop();
private:
public:
	DisplayDataCK m_displaydatack;                                   //用于显示回调函数,参数数据及时间戳
	char * m_yuvbuf;												 //存放yuv数据的buf指针,申请buffer在外面
	int m_millis_realtime;                                           //实时的时间戳,每次回调会更新
private:
	int m_screen_width;												 //窗口宽
	int m_screen_height;											 //窗口高
	int m_window_x;													 //窗口的x坐标
	int m_window_y;													 //窗口的y坐标
	int m_yuvdata_width;											 //数据宽
	int m_yuvdata_height;											 //数据高
	WindowRepaintCK m_windowrepaintcallback;						 //窗口重绘的时候,例如最大化最小化窗口,缩放窗口等让窗口重绘的时候调用。//从而接收消息循环 
	char m_shader_vsh_pathname[256];								 //shader的vsh源码位置
	char m_shader_fsh_pathname[256];								 //shader的fsh源码位置
	GLuint m_textureid_y, m_textureid_u, m_textureid_v;              //纹理的名称,并且,该纹理的名称在当前的应用中不能被再次使用。
	GLuint m_textureUniformY, m_textureUniformU,m_textureUniformV;   //用于纹理渲染的变量 
};

#endif


//Lvs_OpenGl_Interface.cpp

#include "Lvs_OpenGl_Interface.h"

static cclass_opengl_interface * copengl_interface = NULL;

int lvs_opengl_interface_init(int screen_width,int screen_height,
	int window_x, int window_y,
	int yuvdata_width,int uvdata_height,
	char * shader_vsh_pathname,char * shader_fsh_pathname,
	DisplayDataCK displaydatack,
	WindowRepaintCK windowrepaintcallback)
{
	int ret = 0;
	printf("Device : lvs_opengl_interface_init\n");
	if(copengl_interface == NULL)
	{
		copengl_interface = new cclass_opengl_interface();
		//初始化
		copengl_interface->initopengl(screen_width,screen_height,
			window_x,window_y,
			yuvdata_width,uvdata_height,
			shader_vsh_pathname,shader_fsh_pathname,
			displaydatack,
			windowrepaintcallback);

		//初始化着色器,类似于告GPU当传进去数据的时候采用什么样的规则。
		copengl_interface->InitShaders();
	}
	return ret;
}

void lvs_opengl_interface_uninit()
{
	printf("Device : lvs_opengl_interface_uninit\n");

	if(copengl_interface)
	{
		delete copengl_interface;
		copengl_interface = NULL;
	}
	return ;
}

void lvs_opengl_interface_write(int value)
{
	 //这里如果有可能则调成类的成员函数,以后处理,暂时不知道怎么解决类成员函数递归
	TimerFunc1(value);
}

void lvs_opengl_interface_messageloop()
{
	copengl_interface->messageloop();
}

void TimerFunc1(int value)
{
	int ret = 0;

	//调用回调函数获取数据
	copengl_interface->m_displaydatack((void **)&copengl_interface->m_yuvbuf,&copengl_interface->m_millis_realtime);

	//这里做具体的处理
	ret = copengl_interface->DisplayImage(NULL);

	//因为glut的定时器是调用一次才产生一次定时,所以如果要持续产生定时的话,
	//在定时函数末尾再次调用glutTimerFunc
	//这里如果有可能则调成类的成员函数,以后处理,暂时不知道怎么解决类成员函数递归
	glutTimerFunc(copengl_interface->m_millis_realtime, TimerFunc1, 0);
}

cclass_opengl_interface::cclass_opengl_interface()
{
	m_screen_width = 0; 
	m_screen_height = 0; 
	m_window_x = 0;
	m_window_y = 0;
	m_yuvdata_width = 0;
	m_yuvdata_height = 0; 
	memset(m_shader_vsh_pathname,0,256);
	memset(m_shader_fsh_pathname,0,256);
	m_windowrepaintcallback = NULL;
	m_textureid_y = 0;
	m_textureid_u = 0;
	m_textureid_v = 0;
	m_textureUniformY = 0;
	m_textureUniformU = 0;
	m_textureUniformV = 0;
	m_displaydatack = NULL;
	m_yuvbuf = NULL;
	m_millis_realtime = 0;
}

cclass_opengl_interface::~cclass_opengl_interface()
{
	m_screen_width = 0; 
	m_screen_height = 0; 
	m_window_x = 0;
	m_window_y = 0;
	m_yuvdata_width = 0;
	m_yuvdata_height = 0; 
	memset(m_shader_vsh_pathname,0,256);
	memset(m_shader_fsh_pathname,0,256);
	m_windowrepaintcallback = NULL;
	m_textureid_y = 0;
	m_textureid_u = 0;
	m_textureid_v = 0;
	m_textureUniformY = 0;
	m_textureUniformU = 0;
	m_textureUniformV = 0;
	m_displaydatack = NULL;
	m_yuvbuf = NULL;
	m_millis_realtime = 0;
}

int cclass_opengl_interface::initopengl(int screen_width,int screen_height,
	int window_x, int window_y,
	int yuvdata_width,int uvdata_height,
	char * shader_vsh_pathname,char * shader_fsh_pathname,
	DisplayDataCK displaydatack,
	WindowRepaintCK windowrepaintcallback)
{
	int ret = 0;

	m_screen_width = screen_width; 
	m_screen_height = screen_height; 
	m_window_x = window_x;
	m_window_y = window_y;
	m_yuvdata_width = yuvdata_width;
	m_yuvdata_height = uvdata_height; 
	sprintf(m_shader_vsh_pathname,"%s",shader_vsh_pathname);
	sprintf(m_shader_fsh_pathname,"%s",shader_fsh_pathname);
	m_windowrepaintcallback = windowrepaintcallback;
	m_displaydatack = displaydatack;

	//初始化 GLUT opengl函数库
	int zwg_argc=1;
	//添加函数库名称
	char* zwg_argv[]={"ZWG_GLUT"};  
	glutInit(&zwg_argc, zwg_argv); 

	//设置显示模型
	glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA /*| GLUT_STENCIL | GLUT_DEPTH*/);
	//设置在屏幕上的起始位置
	glutInitWindowPosition(m_window_x, m_window_y);
	//设置显示窗口大小,宽高
	glutInitWindowSize(m_screen_width, m_screen_height);
	//设置显示窗口名称
	glutCreateWindow("Lvs_OpenGl");

	//OpenGL扩展库是个简单的工具,opengl纹理程序用的着色程序初始化
	GLenum lvs_glew = glewInit();

	//输出版本号
	printf("OpenGl Version: %s\n", glGetString(GL_VERSION));

	//设置绘制窗口时候接收消息的回调函数
	glutDisplayFunc(m_windowrepaintcallback);

	ret = 1;
	return ret;
}

void cclass_opengl_interface::InitShaders()
{
	GLint vertCompiled, fragCompiled;  //调试 shader的返回值,如果一切正常返回GL_TRUE代,否则返回GL_FALSE。
	GLuint p;      //Program着色器程序的id
	GLint linked;  //调试 param的返回值,如果一切正常返回GL_TRUE代,否则返回GL_FALSE。

	GLint v, f;  //shader的id;
	char vs[1024 *10] = {0}; //shader源码的字串vsh 是Vertex Shader(顶点着色器)
	char fs[1024 *10] = {0}; //shader源码的字串fsh 是Fragment Shader(片元着色器)  
	const char * vs_buf = vs;
	const char * fs_buf = fs;

	//shader的处理类似于将OpenGL Shader Language,简称GLSL的源码编译成2进制程序
	//“vsh负责搞定像素位置,填写gl_Posizion;fsh负责搞定像素外观,填写 gl_FragColor。”
	//Shader: step1 创建两个shader 实例。
	v = glCreateShader(GL_VERTEX_SHADER);
	f = glCreateShader(GL_FRAGMENT_SHADER);
	//着色器源码
	//penGL的着色器有.fsh和.vsh两个文件。这两个文件在被编译和链接后就可以产生可执行程序与GPU交互。
	//shader.vsh 是Vertex Shader(顶点着色器),用于顶点计算,可以理解控制顶点的位置,在这个文件中我们通常会传入当前顶点的位置,和纹理的坐标。
	//shader.fsh 是Fragment Shader(片源着色器),在这里面我可以对于每一个像素点进行重新计算。
	//将Vertex Shader和Fragment Shader源码读取到字符串中。
	FILE * infile_vsh = fopen(m_shader_vsh_pathname, "rb");
	int len_vsh = fread((char *)vs, 1, 1024 *10, infile_vsh);
	fclose(infile_vsh);
	infile_vsh = NULL;
	vs[len_vsh] = 0;
	FILE * infile_fsh = fopen(m_shader_fsh_pathname, "rb");
	int len_fsh = fread((char *)fs, 1, 1024 *10, infile_fsh);
	fclose(infile_fsh);
	infile_fsh = NULL;
	vs[len_fsh] = 0;
	//Shader: step2 给Shader实例指定源码。
	glShaderSource(v, 1, &vs_buf,NULL);
	glShaderSource(f, 1, &fs_buf,NULL);
	//Shader: step3 在线编译Shader源码。
	glCompileShader(v);
	//Shader: step4 调试一个Shader
	//void glGetShaderiv(	GLuint shader,GLenum pname,GLint *params);
	//params:返回值,如果一切正常返回GL_TRUE代,否则返回GL_FALSE。
	glGetShaderiv(v, GL_COMPILE_STATUS, &vertCompiled);
	glCompileShader(f);
	glGetShaderiv(f, GL_COMPILE_STATUS, &fragCompiled);

	//Program有点类似于一个程序的链接器。program对象提供了把需要做的事连接在一起的机制。在一个program中,shader对象可以连接在一起。
	//Program 这个类似于运行OpenGL Shader Language,简称GLSL的源码编译成2进制程序的执行环境,链接器
	//Program: Step1 创建program
	p = glCreateProgram(); 
	//Program: Step2 绑定shader到program
	glAttachShader(p,v);
	glAttachShader(p,f); 

	//通过glBindAttribLocation()把“顶点属性索引”绑定到“顶点属性名” 
	glBindAttribLocation(p, ATTRIB_VERTEX, "vertexIn");
	//通过glBindAttribLocation()把“像素纹理属性索引”绑定到“像素纹理属性名” 
	glBindAttribLocation(p, ATTRIB_TEXTURE, "textureIn");
	//Program: Step3 链接program
	glLinkProgram(p);
	//void glGetProgramiv (int program, int pname, int[] params, int offset)   
	//参数含义: 
	//	program:一个着色器程序的id; 
	//	pname:GL_LINK_STATUS; 
	//	param:返回值,如果一切正常返回GL_TRUE代,否则返回GL_FALSE。
	glGetProgramiv(p, GL_LINK_STATUS, &linked);  
	//Program: Step4 在链接了程序以后,我们可以使用glUseProgram()函数来加载并使用链接好的程序
	glUseProgram(p);

	//获取片源着色器源码中的变量,用于纹理渲染
	m_textureUniformY = glGetUniformLocation(p, "tex_y");
	m_textureUniformU = glGetUniformLocation(p, "tex_u");
	m_textureUniformV = glGetUniformLocation(p, "tex_v"); 

	//顶点数组(物体表面坐标取值范围是-1到1,数组坐标:左下,右下,左上,右上)
#if TEXTURE_ROTATE
	static const GLfloat vertexVertices[] = {
		-1.0f, -0.5f,
		0.5f, -1.0f,
		-0.5f,  1.0f,
		1.0f,  0.5f,
	};    
#else
	static const GLfloat vertexVertices[] = {
		-1.0f, -1.0f,
		1.0f, -1.0f,
		-1.0f,  1.0f,
		1.0f,  1.0f,
	};    
#endif

	//像素,纹理数组(纹理坐标取值范围是0-1,坐标原点位于左下角,数组坐标:左上,右上,左下,右下,如果先左下,图像会倒过来)
#if TEXTURE_HALF
	static const GLfloat textureVertices[] = {
		0.0f,  1.0f,
		0.5f,  1.0f,
		0.0f,  0.0f,
		0.5f,  0.0f,
	}; 
#else
	static const GLfloat textureVertices[] = {
		0.0f,  1.0f,
		1.0f,  1.0f,
		0.0f,  0.0f,
		1.0f,  0.0f,
	}; 
#endif

	//定义顶点数组
	glVertexAttribPointer(ATTRIB_VERTEX, 2, GL_FLOAT, 0, 0, vertexVertices);
	//启用属性数组
	glEnableVertexAttribArray(ATTRIB_VERTEX); 
	//定义像素纹理数组
	glVertexAttribPointer(ATTRIB_TEXTURE, 2, GL_FLOAT, 0, 0, textureVertices);
	//启用属性数组
	glEnableVertexAttribArray(ATTRIB_TEXTURE);

	//初始化纹理
	glGenTextures(1, &m_textureid_y); 
	//绑定纹理
	glBindTexture(GL_TEXTURE_2D, m_textureid_y);    
	//设置该纹理的一些属性
	glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

	glGenTextures(1, &m_textureid_u);
	glBindTexture(GL_TEXTURE_2D, m_textureid_u);   
	glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

	glGenTextures(1, &m_textureid_v); 
	glBindTexture(GL_TEXTURE_2D, m_textureid_v);    
	glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}

int cclass_opengl_interface::DisplayImage(void * parm)
{
	int ret = 0;

	unsigned char * yuvplaner[3] = {0};                             //存放yuv数据的分量数组(y,u,v) 
	//关联到yuv数据的分量数组
	yuvplaner[0] = (unsigned char *)m_yuvbuf;
	yuvplaner[1] = yuvplaner[0] + m_yuvdata_width*m_yuvdata_height;
	yuvplaner[2] = yuvplaner[1] + m_yuvdata_width*m_yuvdata_height/4;

	//Clear
	//清除颜色设为黑色,把整个窗口清除为当前的清除颜色,glClear()的唯一参数表示需要被清除的缓冲区。
	glClearColor(0.0,0.0,0.0,0.0);
	glClear(GL_COLOR_BUFFER_BIT);

	//显卡中有N个纹理单元(具体数目依赖你的显卡能力),每个纹理单元(GL_TEXTURE0、GL_TEXTURE1等)都有GL_TEXTURE_1D、GL_TEXTURE_2D等	
	//Y
	//选择当前活跃的纹理单元
	glActiveTexture(GL_TEXTURE0);
	//允许建立一个绑定到目标纹理的有名称的纹理
	glBindTexture(GL_TEXTURE_2D, m_textureid_y);
	//根据指定的参数,生成一个2D纹理(Texture)。相似的函数还有glTexImage1D、glTexImage3D。
	glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, m_yuvdata_width, m_yuvdata_height, 0, GL_RED, GL_UNSIGNED_BYTE, yuvplaner[0]); 
	glUniform1i(m_textureUniformY, 0);     //设置纹理,按照前面设置的规则怎样将图像或纹理贴上(参数和选择的活跃纹理单元对应,GL_TEXTURE0)
	
	//U
	glActiveTexture(GL_TEXTURE1);
	glBindTexture(GL_TEXTURE_2D, m_textureid_u);
	glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, m_yuvdata_width/2, m_yuvdata_height/2, 0, GL_RED, GL_UNSIGNED_BYTE, yuvplaner[1]);       
	glUniform1i(m_textureUniformU, 1);
	//V
	glActiveTexture(GL_TEXTURE2);
	glBindTexture(GL_TEXTURE_2D, m_textureid_v);
	glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, m_yuvdata_width/2, m_yuvdata_height/2, 0, GL_RED, GL_UNSIGNED_BYTE, yuvplaner[2]);    
	glUniform1i(m_textureUniformV, 2); 

	// 绘制
	glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
	//双缓冲显示
	glutSwapBuffers();
	//单缓冲显示
	//glFlush();

	return 1;
}

void cclass_opengl_interface::messageloop()
{
	//glutMainLoop进入GLUT事件处理循环,让所有的与“事件”有关的函数调用无限循环。开始时间循环
	glutMainLoop();
}


//main.cpp

#include "Lvs_OpenGl_Interface.h"

//要显示的yuv文件路径及名称
#define YUV_STREAM_PATH_NAME  "../yuv_stream/352_288_yuv420p.yuv"
//yuv数据宽
#define YUVDATA_WIDTH  352 
//yuv数据高
#define YUVDATA_HEIGHT 288
//shader的vsh源码位置
#define SHADER_VSH_SOURCE  "../opengl_win32/Shader.vsh"
//shader的fsh源码位置
#define SHADER_FSH_SOURCE  "../opengl_win32/Shader.fsh"

static FILE * m_inyuvfile = NULL;                                       //yuv文件句柄
static unsigned char m_yuvbuf[YUVDATA_WIDTH*YUVDATA_HEIGHT*3/2];        //存放yuv数据的buf
static unsigned char * m_yuvplane[3] = {0};                             //存放yuv数据的分量数组(y,u,v) 
static int m_timer_realtime = 40;                                       //每一次回调渲染数据定时器时间,可根据时间戳变化,毫秒


//窗口重绘的时候,例如最大化最小化窗口,缩放窗口等让窗口重绘的时候调用。
//从而接收消息循环
void WindowRepaintCallback();
//回调读取数据函数,参数数据及时间戳
int DisplayDataCallback(void * data,int * timer_millis);

//窗口重绘的时候,例如最大化最小化窗口,缩放窗口等让窗口重绘的时候调用。
//从而接收消息循环
void WindowRepaintCallback()
{
	//可以做一些处理
	printf("窗口重绘了...\n");
}

int DisplayDataCallback(void ** data,int * timer_millis)
{
	int ret = 0;

	//循环读取文件
	ret = fread(m_yuvbuf, 1, YUVDATA_WIDTH*YUVDATA_HEIGHT*3/2, m_inyuvfile);
	if (ret != YUVDATA_WIDTH*YUVDATA_HEIGHT*3/2)
	{
		//seek到文件开头
		fseek(m_inyuvfile, 0, SEEK_SET);
		fread(m_yuvbuf, 1,YUVDATA_WIDTH*YUVDATA_HEIGHT*3/2, m_inyuvfile);
	}

	//将数据返回去
	*data = m_yuvbuf;
	*timer_millis = m_timer_realtime;

	return ret;
}

int main()
{
	int ret = 0;

	//打开 YUV420P 文件
	if((m_inyuvfile = fopen(YUV_STREAM_PATH_NAME, "rb")) == NULL)
	{
		printf("filed open file : %s\n",YUV_STREAM_PATH_NAME);
		return getchar();
	}
	else
	{
		printf("success open file : %s\n",YUV_STREAM_PATH_NAME);
	}

	//初始化
	ret = lvs_opengl_interface_init(500,500,
			100,100,
			YUVDATA_WIDTH,YUVDATA_HEIGHT,
			SHADER_VSH_SOURCE,SHADER_FSH_SOURCE,
			DisplayDataCallback,
			WindowRepaintCallback);

	//渲染,带定时器,数据回调,及渲染时间回调
	lvs_opengl_interface_write(m_timer_realtime);

	//glutMainLoop进入GLUT事件处理循环,让所有的与“事件”有关的函数调用无限循环。开始时间循环
	lvs_opengl_interface_messageloop();

	//关闭yuv420p文件
	if (m_inyuvfile != NULL)
	{
		fclose(m_inyuvfile);
		m_inyuvfile = NULL;
	}
	
	return 1;
}


程序运行效果:



暂时不知道怎么解决类成员函数递归,以后待解决。


本demo还需完善。

如有错误请指正:

交流请加QQ群:62054820
QQ:379969650.




  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值