上一篇文章OpenGL ES入门教程(一)编写第一个OpenGL程序,我们创建了自己的第一个OpenGL程序,实现了绘制红色背景的Activity页面,算是OpenGL ES的hello world程序吧。本篇文章基于上一篇文章基础上讲解如何使用OpenGL绘制一张平面桌子,桌子由一个长方形构成,且长方形中间绘制一条线,长方形两头绘制两个点。文章中提到的示例代码我都共享到gitee上了,各位博友可以从我的gitee仓库OpenGL_ES_DEMO下载完整的项目代码,代码都有很详细的提交记录。
绘制图形都是在OpenGL渲染器类中完成,所以,本篇文章的所有代码都是在上一篇文章中定义的AirHockeyRenderer类中编写。
OpenGL包括三类基础图形,点,直线,三角形。其余的任何图形,都是由这三种基本图形组成。
因此如果我们想绘制一个前言中所述的平面桌子,可以由两个三角形组成一个长方形,并在长方形的中间绘制一条直线,两端绘制两个点,如下图所示:
无论是x还是y坐标,OpenGL都会把屏幕映射到[-1,1]的范围内。 即屏幕的左边对应x轴的-1,右边对应+1;屏幕的底边对应y轴的-1,顶边对应+1;因此如果将上面的图形绘制到屏幕中间,需要的顶点坐标如下图所示:
如上图所示坐标数据,我们将每个顶点(由x坐标和y坐标组成)的数据存储到数组中,上图主要包含的OpenGL基本图形是两个三角形,一条线,两个点,其中三角形的顶点我们统一按照逆时针方向进行存储,这一步数据准备,我可以将它放在渲染器类的构造函数中,最终定义的顶点数据如下:
private Context context;//后面我们加载着色器需要用到该上下文,因此也通过构造函数传进去
public AirHockeyRenderer(Context context)
{
this.context = context;
float[] tableVerticesWithTriangles = {
/**
无论是x还是y坐标,OpenGL都会把屏幕映射到[-1,1]的范围内。
即屏幕的左边对应x轴的-1,右边对应+1;
屏幕的底边对应y轴的-1,顶边对应+1
*/
// Triangle 1
-0.5f, -0.5f,
0.5f, 0.5f,
-0.5f, 0.5f,
// Triangle 2
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
// Line 1
-0.5f, 0f,
0.5f, 0f,
// Mallets
0f, -0.25f,
0f, 0.25f
};
}
OpenGL作为本地系统库直接运行在硬件上,无法直接读取java程序中定义的数据,因此我们需要把上面定义的顶点数据复制到本地内存中。具体实现代码如下:
private Context context;
private static final int BYTES_PER_FLOAT = 4;
private final FloatBuffer vertexData;
public AirHockeyRenderer(Context context)
{
this.context = context;
float[] tableVerticesWithTriangles = {
/**
无论是x还是y坐标,OpenGL都会把屏幕映射到[-1,1]的范围内。
即屏幕的左边对应x轴的-1,右边对应+1;
屏幕的底边对应y轴的-1,顶边对应+1
*/
// Triangle 1
-0.5f, -0.5f,
0.5f, 0.5f,
-0.5f, 0.5f,
// Triangle 2
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
// Line 1
-0.5f, 0f,
0.5f, 0f,
// Mallets
0f, -0.25f,
0f, 0.25f
};
vertexData = ByteBuffer
//申请本地内存空间大小,单位为字节。tableVerticesWithTriangles中存储的是float类型数据,由32bit组成,即4个字节组成;
.allocateDirect(tableVerticesWithTriangles.length * BYTES_PER_FLOAT)
//本地内存空间的一种排序方式
.order(ByteOrder.nativeOrder())
//转换为我们需要的FloatBuffer类型
.asFloatBuffer();
//将tableVerticesWithTriangles中的数据拷贝到本地内存中
vertexData.put(tableVerticesWithTriangles);
}
OpenGL绘制图形的流程称为OpenGL管道(pipeline)。上面我们定义了顶点数据,并将其拷贝到了本地内存,下面就是将本地内存中的图形顶点数据在OpenGL管道进行流动,并通过着色器告诉GPU如何绘制数据。
着色器是一种只能运行在GPU上的特殊类型程序。OpenGL着色器分为顶点着色器和片段着色器两种类型:
着色器处理完成后(颜色生成),openGL将它们写入一块称为帧缓冲区(frame buffer)的内存块,然后,Android把这分帧缓冲区中的数据显示在屏幕上。
OpenGL管道(pipeline)流程如下:
其中,光栅化技术是指:移动设备的显示屏通过大量像素的堆积(红绿蓝三种颜色不同比例的混合,就足以创造出人眼可见范围内的颜色),在视觉上创造出巨量颜色范围的技术。而OpenGL光栅化就是把每个点,直线,三角形,分解成大量的小片段,通常情况下,一个片段直接映射到屏幕的一个像素。
OpenGL着色器的定义采用着色器特定的语言(语法结构类似C语言),着色器文件后缀名为glsl(OpenGL shader language)。我们在工程的res目录下新建一个raw文件夹,并在raw文件夹中创建顶点着色器文件simple_vertex_shader.glsl和片段着色器文件simple_fragment_shader.glsl,如下图所示:
/*
attribute:定义顶点类型位置数据的特定标识
vec4:一种包含4个分量的向量数据类型(x,y,z,w)
其中x,y,z代表顶点的三维位置坐标,w是一个特殊坐标,后面会讲解
a_Position:变量名称,该名称后面OpenGL的glGetAttribLocation方法要用到,
如果修改后面就要一起修改
*/
attribute vec4 a_Position;
//和C语言类似,main函数是着色器的入口函数
void main()
{ //gl_Position :OpenGL特定的变量名,用于存储我们定义的顶点数据
gl_Position = a_Position;
//gl_PointSize:OpenGL特定的变量名,用于存储点的大小
gl_PointSize = 10.0;
}
片段着色器内容如下:
//OpenGL定义float数据类型的精度(lowp;mediump;highp),就像java代码中浮点型选择float类型还是double类型。
//精度是以性能为代价的,这里选择mediump
precision mediump float;
/*
uniform:定义片段颜色的一种特殊标识
vec4:一种包含4个分量的向量数据类型(r,g,b,a),分别代码红,绿,蓝,透明度。
其中rgba的取值范围是0-1,rgba色彩不了解的可以去其它文章了解一下。
u_Color:变量名称,该名称后面OpenGL的glGetUniformLocation方法要用到,
如果修改后面就要一起修改
*/
uniform vec4 u_Color;
//和C语言类似,main函数是着色器的入口函数
void main()
{
gl_FragColor = u_Color;
}
加载着色器其实非常简单就是通过Java IO流的方式将着色器文件中的内容读取为一个字符,以供OpenGL后面编译着色器使用。我以前写过一篇详细的Java IO流文章,如果有兴趣的博友可以移步去看看Java IO流最全详解
为了复用代码,我们定义一个TextResourceReader类,并在类中实现一个静态方法readTextFileFromResource,专用于加载着色器,具体实现代码如下:
public class TextResourceReader {
public static String readTextFileFromResource(Context context, int resourceId) {
StringBuilder body = new StringBuilder();
try {
InputStream inputStream = context.getResources().openRawResource(resourceId);
InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
String newLine;
while ((newLine = bufferedReader.readLine()) != null)
{
body.append(newLine);
body.append("\n");
}
}
catch (IOException e)
{
throw new RuntimeException("Could not open resource: " + resourceId, e);
}
catch (Resources.NotFoundException e)
{
throw new RuntimeException("Resource not found: " + resourceId, e);
}
return body.toString();
}
}
因为加载着色器算是OpenGL绘图的初始化操作,我们可以在surface创建的时候调用一次该逻辑,即在渲染器类重写的onSurfaceCreated方法中调用加载着色器的逻辑,具体代码如下所示:
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
glClearColor(1.0f, 0.0f, 0.0f, 0.0f);
//加载着色器
String vertexShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_vertex_shader);
String fragmentShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_fragment_shader);
}
编译着色器和将着色器链接到OpenGL程序对象都是比较固定的渲染步骤,为了重复利用它们,我们定义一个ShaderHelper类,专门用于编译着色器,将着色器链接为OpenGL程序对象和验证OpenGL程序对象的有效性。
编译着色的实现流程比较固定,我们只需要会用就行了,不必死记硬背,具体实现代码如下:
/**
* Compiles a shader, returning the OpenGL object ID.
* 1. glCreateShader 创建着色器对象 0代表失败,检查创建状态
* 2. glShaderSource 向着色器对象中上传着色器源码
* 3. glCompileShader 着色器对象编译源码
* 4. glGetShaderiv 获取编译状态,若编译失败,则删除着色器对象id,否则返回着色器对象id
* 5. glGetShaderInfoLog 获取编译结果的详细信息
* 6. 如果编译失败,glDeleteShader删除渲染器对象id
* 7. 编译成功,返回渲染器对象id
* @param type 着色器类型:顶点着色器 GL_VERTEX_SHADER,片段着色器 GL_FRAGMENT_SHADER
* @param shaderCode 加载的着色器代码
* @return
*/
private static int compileShader(int type, String shaderCode) {
// Create a new shader object.
final int shaderObjectId = glCreateShader(type);
if (shaderObjectId == 0) {
if (LoggerConfig.ON) {
Log.w(TAG, "Could not create new shader.");
}
return 0;
}
// Pass in the shader source.
glShaderSource(shaderObjectId, shaderCode);
// Compile the shader.
glCompileShader(shaderObjectId);
// Get the compilation status.
final int[] compileStatus = new int[1];
glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatus, 0);
if (LoggerConfig.ON) {
// Print the shader info log to the Android log output.
Log.v(TAG, "Results of compiling source:" + "\n" + shaderCode + "\n:"
+ glGetShaderInfoLog(shaderObjectId));
}
// Verify the compile status.
if (compileStatus[0] == 0) {
// If it failed, delete the shader object.
glDeleteShader(shaderObjectId);
if (LoggerConfig.ON) {
Log.w(TAG, "Compilation of shader failed.");
}
return 0;
}
// Return the shader object ID.
return shaderObjectId;
}
/**
* Loads and compiles a vertex shader, returning the OpenGL object ID.
*/
public static int compileVertexShader(String shaderCode) {
return compileShader(GL_VERTEX_SHADER, shaderCode);
}
/**
* Loads and compiles a fragment shader, returning the OpenGL object ID.
*/
public static int compileFragmentShader(String shaderCode) {
return compileShader(GL_FRAGMENT_SHADER, shaderCode);
}
因为编译着色器算是OpenGL绘图的初始化操作,我们可以在surface创建的时候调用一次该逻辑,即在渲染器类重写的onSurfaceCreated方法中调用编译着色器的逻辑,具体代码如下所示:
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
glClearColor(1.0f, 0.0f, 0.0f, 0.0f);
//加载着色器
String vertexShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_vertex_shader);
String fragmentShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_fragment_shader);
//编译着色器
int vertexShader = ShaderHelper.compileVertexShader(vertexShaderSource);
int fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderSource);
}
将着色器链接为OpenGL程序对象的实现流程也比较固定,我们只需要会用就行了,不必死记硬背,具体实现代码如下:
/**
*
* Links a vertex shader and a fragment shader together into an OpenGL
* program. Returns the OpenGL program object ID, or 0 if linking failed.
*
* 1. glCreateProgram 创建OpenGL链接程序对象,获取对象id, 对象id为0代表创建失败
* 2. glAttachShader 附上着色器
* 3. glLinkProgram 链接程序,把着色器联合起来
* 4. glGetProgramiv 获取链接状态,若成功则返回链接对象id,否则glDeleteProgram删除链接对象
*
* @param vertexShaderId 顶点着色器对象id
* @param fragmentShaderId 片段着色器对象id
* @return
*/
public static int linkProgram(int vertexShaderId, int fragmentShaderId) {
// Create a new program object.
final int programObjectId = glCreateProgram();
if (programObjectId == 0) {
if (LoggerConfig.ON) {
Log.w(TAG, "Could not create new program");
}
return 0;
}
// Attach the vertex shader to the program.
glAttachShader(programObjectId, vertexShaderId);
// Attach the fragment shader to the program.
glAttachShader(programObjectId, fragmentShaderId);
// Link the two shaders together into a program.
glLinkProgram(programObjectId);
// Get the link status.
final int[] linkStatus = new int[1];
glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0);
if (LoggerConfig.ON) {
// Print the program info log to the Android log output.
Log.v(TAG, "Results of linking program:\n"
+ glGetProgramInfoLog(programObjectId));
}
// Verify the link status.
if (linkStatus[0] == 0) {
// If it failed, delete the program object.
glDeleteProgram(programObjectId);
if (LoggerConfig.ON) {
Log.w(TAG, "Linking of program failed.");
}
return 0;
}
// Return the program object ID.
return programObjectId;
}
/**
* Validates an OpenGL program. Should only be called when developing the application.
* 1. glValidateProgram验证链接到OpenGL程序对象的有效性
* 2. glGetProgramiv获取OpenGL程序对象有效性的状态,如果返回0代表无效,否则代表链接OpenGL程序对象成功
* @param programObjectId
* @return
*/
public static boolean validateProgram(int programObjectId) {
glValidateProgram(programObjectId);
final int[] validateStatus = new int[1];
glGetProgramiv(programObjectId, GL_VALIDATE_STATUS, validateStatus, 0);
Log.v(TAG, "Results of validating program: " + validateStatus[0]
+ "\nLog:" + glGetProgramInfoLog(programObjectId));
return validateStatus[0] != 0;
}
因为将着色器链接为OpenGL程序对象算是OpenGL绘图的初始化操作,我们可以在surface创建的时候调用一次该逻辑,即在渲染器类重写的onSurfaceCreated方法中实现链接OpenGL程序对象的逻辑,具体代码如下所示:
private int program;
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
glClearColor(1.0f, 0.0f, 0.0f, 0.0f);
//加载着色器
String vertexShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_vertex_shader);
String fragmentShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_fragment_shader);
//编译着色器
int vertexShader = ShaderHelper.compileVertexShader(vertexShaderSource);
int fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderSource);
//将着色器链接到OpenGL程序
program = ShaderHelper.linkProgram(vertexShader, fragmentShader);
//打印OpenGL程序对象的有效性信息
if (LoggerConfig.ON) {
ShaderHelper.validateProgram(program);
}
}
至此,我们已经完成了所要绘制图形的顶点数据定义,着色器定义、加载、编译,以及OpenGL程序对象获取。下面我们需要通过OpenGL程序对象获取着色器中定义的属性,并将着色器需要的数据与我们拷贝到本地的数据相关联,继而完成OpenGL绘图的所有前置操作。因为这一步仍然属于OpenGL绘图的初始化或者前置工作,我们可以在surface创建的时候调用一次该逻辑,即在渲染器类重写的onSurfaceCreated方法中调用实现该逻辑,具体实现代码如下:
//这个字符串一定要和片段着色器中定义的属性名一致
private static final String U_COLOR = "u_Color";
private int uColorLocation;
//这个字符串一定要和顶点着色器中定义的属性名一致
private static final String A_POSITION = "a_Position";
private int aPositionLocation;
//每个顶点由两个浮点数组成:x,y
private static final int POSITION_COMPONENT_COUNT = 2;
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
glClearColor(1.0f, 0.0f, 0.0f, 0.0f);
//加载着色器
String vertexShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_vertex_shader);
String fragmentShaderSource = TextResourceReader.readTextFileFromResource(context, R.raw.simple_fragment_shader);
//编译着色器
int vertexShader = ShaderHelper.compileVertexShader(vertexShaderSource);
int fragmentShader = ShaderHelper.compileFragmentShader(fragmentShaderSource);
//将着色器链接到OpenGL程序
program = ShaderHelper.linkProgram(vertexShader, fragmentShader);
//打印OpenGL程序对象的有效性信息
if (LoggerConfig.ON) {
ShaderHelper.validateProgram(program);
}
/*
将着色器需要的数据与拷贝到本地的数组相关联
*/
//告诉OpenGL在绘制任何东西到屏幕上时候,使用这里定义程序
glUseProgram(program);
//获取片段着色器中uniform的颜色属性
uColorLocation = glGetUniformLocation(program, U_COLOR);
//获取顶点着色器中(attribute)位置属性
aPositionLocation = glGetAttribLocation(program, A_POSITION);
//告诉OpenGL从vertexData中读取a_Position的数据
vertexData.position(0);//将缓冲区数据中的指针指向第一个数据,即从第一个数据开始读
/*
将着色器中的位置属性与本地顶点数据相关联。
aPositionLocation:着色器中定义的位置属性
POSITION_COMPONENT_COUNT:每次从本地数组中读取两个数据(即x,y代表一个顶点坐标)
GL_FLOAT:OpenGL采用的数据类型,因为我们定义的是浮点数数组,所以采用GL_FLOAT
vertexData:要关联的本地数据列表
*/
glVertexAttribPointer(aPositionLocation, POSITION_COMPONENT_COUNT, GL_FLOAT,
false, 0, vertexData);
//使能顶点数组
glEnableVertexAttribArray(aPositionLocation);
}
glVertexAttribPointer方法所需参数的详细说明如下图所示:
至此,我们已经完成了使用OpenGL绘制图形的所有必要的前置步骤,下面就是绘制图形了,绘制图形的逻辑在渲染类中重写的onDrawFrame方法中实现。
OpenGL绘制基本图形的步骤如下:
public static native void glUniform4f(
int location,//片段着色器的颜色属性
float x, //rgb色彩的r分量
float y, //rgb色彩的g分量
float z, //rgb色彩的b分量
float w //rgb色彩的透明度分量
);
public static native void glDrawArrays(
int mode, //要绘制的基本图元类型:三角形 GL_TRIANGLES;直线 GL_LINES;点 GL_POINTS
int first,//从本地顶点数组中读取数据的开始位置
int count //一共读取多少个顶点,上面glVertexAttribPointer方法中我定义了一个顶点由2个数据组成
);
因此,如果我们想绘制一个白色的三角形,实现代码如下:
//更新着色器中u_Color的值(白色)
glUniform4f(uColorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
//因为三角形由三个顶点组成,因此我们从本地顶点数据列表中的第一个顶点开始,连续读取3个顶点
glDrawArrays(GL_TRIANGLES, 0, 3);
@Override
public void onDrawFrame(GL10 gl) {
glClear(GL_COLOR_BUFFER_BIT);
/*
绘制桌子
*/
//更新着色器中u_Color的值(白色)
glUniform4f(uColorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
//参数1:绘制三角形;参数2:从顶点数组的开头开始读顶点;参数3:读取6个顶点(即绘制两个三角形)
//之前glVertexAttribPointer告诉过OpenGL每个顶点的位置包含两个浮点分量,因此OpenGL会使用vertexData中如下12个浮点数绘制两个三角形
/**
// Triangle 1
-0.5f, -0.5f,
0.5f, 0.5f,
-0.5f, 0.5f,
// Triangle 2
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
*/
glDrawArrays(GL_TRIANGLES, 0, 6);
/*
绘制分割线
*/
//更新u_Color的值(红色)
glUniform4f(uColorLocation, 1.0f, 0.0f, 0.0f, 1.0f);
//参数1:绘制直线; 参数2:从顶点数组的第6个顶点之后(即第7个顶点)开始读取;参数3:读取两个顶点
glDrawArrays(GL_LINES, 6, 2);
/*
绘制两个木槌
*/
//更新u_Color的值(蓝色)
glUniform4f(uColorLocation, 0.0f, 0.0f, 1.0f, 1.0f);
glDrawArrays(GL_POINTS, 8, 1);
//更新u_Color的值(绿色)
glUniform4f(uColorLocation, 0.0f, 1.0f, 0.0f, 1.0f);
glDrawArrays(GL_POINTS, 9, 1);
}
至此,我们已经实现了使用OpenGL绘制一个平面桌子的全部代码,可以从我的gitee仓库OpenGL_ES_DEMO下载完整的项目代码,并执行git reset --hard f1a8e96f0e126814be1a4275459bd7be37c183c0切到文章目前所实现代码的节点,运行程序效果如下:
为了更好的验证我们是否掌握了使用OpenGL灵活绘制图形的能力,可以在以上绘制图形的基础上,再在长方形内添加一个小的长方形,形成桌子边框的效果,各位博友如果能够独立实现这一功能,那么应该对OpenGL基本图形的绘制有了比较熟练的掌握。具体实现思路和代码如下:
float[] tableVerticesWithTriangles = {
/**
无论是x还是y坐标,OpenGL都会把屏幕映射到[-1,1]的范围内。
即屏幕的左边对应x轴的-1,右边对应+1;
屏幕的底边对应y轴的-1,顶边对应+1
*/
// Triangle 1
-0.5f, -0.5f,
0.5f, 0.5f,
-0.5f, 0.5f,
// Triangle 2
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
// Triangle 3
-0.4f, -0.4f,
0.4f, 0.4f,
-0.4f, 0.4f,
// Triangle 4
-0.4f, -0.4f,
0.4f, -0.4f,
0.4f, 0.4f,
// Line 1
-0.5f, 0f,
0.5f, 0f,
// Mallets
0f, -0.25f,
0f, 0.25f
};
@Override
public void onDrawFrame(GL10 gl) {
glClear(GL_COLOR_BUFFER_BIT);
/*
绘制桌子
*/
//更新着色器中u_Color的值(蓝色)
glUniform4f(uColorLocation, 0.0f, 0.0f, 1.0f, 1.0f);
//参数1:绘制三角形;参数2:从顶点数组的开头开始读顶点;参数3:读取6个顶点(即绘制两个三角形)
//之前glVertexAttribPointer告诉过OpenGL每个顶点的位置包含两个浮点分量,因此OpenGL会使用vertexData中如下12个浮点数绘制两个三角形
/**
// Triangle 1
-0.5f, -0.5f,
0.5f, 0.5f,
-0.5f, 0.5f,
// Triangle 2
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
*/
glDrawArrays(GL_TRIANGLES, 0, 6);
/**
* 绘制第二个内长方形,形成边框的效果
*/
//更新着色器中u_Color的值(白色)
glUniform4f(uColorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
glDrawArrays(GL_TRIANGLES, 6, 6);
/*
绘制分割线
*/
//更新u_Color的值(红色)
glUniform4f(uColorLocation, 1.0f, 0.0f, 0.0f, 1.0f);
//参数1:绘制直线; 参数2:从顶点数组的第6个顶点之后(即第7个顶点)开始读取;参数3:读取两个顶点
glDrawArrays(GL_LINES, 12, 2);
/*
绘制两个木槌
*/
//更新u_Color的值(黑色)
glUniform4f(uColorLocation, 0.0f, 0.0f, 0.0f, 1.0f);
glDrawArrays(GL_POINTS, 14, 1);
//更新u_Color的值(绿色)
glUniform4f(uColorLocation, 0.0f, 1.0f, 0.0f, 1.0f);
glDrawArrays(GL_POINTS, 15, 1);
}
下载完整的项目代码后,执行git reset --hard bd8d6607592abcddd1547a5e36a01688772eda64切到实现带边框效果的桌面节点,运行程序效果如下: