通过阅读本文,你将获得以下收获:
1.如何将Bitmap传到Native层处理
2.如何使用代码实现纹理映射
3.通过纹理映射实现一些有趣的效果
上篇回顾
上一篇一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(理论篇)已经详细叙述了纹理的概念以及纹理映射到图元上的原理,都是纯理论,略显枯燥。
今天就将理论付诸实践,一起来看看具体代码如何实现纹理映射。
最后再利用纹理映射来实现一些有意思的效果,绝对不能错过~
代码实战
如何将图片传入Native层
上一篇一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(理论篇)主页有已经说过,纹理就是携带图片信息的容器,所以这里首先要获取到图片的信息(不然还纹理映射个毛线),在android的Java层,获取位图的方式可谓妇孺皆知:
Bitmap bitmap = ((BitmapDrawable) getResources().getDrawable(R.drawable.liyingai)).getBitmap();
因为我们OpenGL代码是在Native层的,那么怎么将Bitmap传给Native层处理呢?其实直接传入即可,在C++层用jobject接收(如果对于ndk还不太熟悉可以看下我之前写的入门文章:初探ndk的世界(一) ),然后ndk已经提供了对应的jnigraphics库来处理Bitmap相关操作,它可以直接操作Bitmap的像素。
使用之前,先要在CmakeList中链接jnigraphics库:
target_link_libraries( # Specifies the target library.
native-lib
GLESv3
EGL
android
jnigraphics # 操作Bitmap的库
# Links the target library to the log library
# included in the NDK.
${log-lib} )
Java层创建绘制纹理的Native方法:
public native void drawTexture(Bitmap bitmap, Object surface);
在Native层对应的方法如下:
Java_com_example_openglstudydemo_YuvPlayer_drawTexture(JNIEnv *env, jobject thiz, jobject bitmap,
jobject surface)
注意到在这里Bitmap对象已经是jobject类型。
首先用jnigraphics库的AndroidBitmap_getInfo方法获取Bitmap对象的相关信息:
/**
* Given a java bitmap object, fill out the {@link AndroidBitmapInfo} struct for it.
* If the call fails, the info parameter will be ignored.
*/
int AndroidBitmap_getInfo(JNIEnv* env, jobject jbitmap,
AndroidBitmapInfo* info);
第一个参数就是JNIEnv指针,第二个参数为Bimtap对象,第三个为结构体AndroidBitmapInfo的指针。
AndroidBitmapInfo为何物呢?其实,它就类似一个水桶,在函数执行完就将数据舀出来,也就是获取到的信息会存放在AndroidBitmapInfo的结构体中,对于图片来说,最常见的信息莫过于宽高、像素格式等:
/** Bitmap info, see AndroidBitmap_getInfo(). */
typedef struct {
/** The bitmap width in pixels. */
uint32_t width;
/** The bitmap height in pixels. */
uint32_t height;
/** The number of byte per row. */
uint32_t stride;
/** The bitmap pixel format. See {@link AndroidBitmapFormat} */
int32_t format;
/** Bitfield containing information about the bitmap.
*
* <p>Two bits are used to encode alpha. Use {@link ANDROID_BITMAP_FLAGS_ALPHA_MASK}
* and {@link ANDROID_BITMAP_FLAGS_ALPHA_SHIFT} to retrieve them.</p>
*
* <p>One bit is used to encode whether the Bitmap uses the HARDWARE Config. Use
* {@link ANDROID_BITMAP_FLAGS_IS_HARDWARE} to know.</p>
*
* <p>These flags were introduced in API level 30.</p>
*/
uint32_t flags;
} AndroidBitmapInfo;
执行AndroidBitmap_getInfo方法的返回值会是以下几种情况,成功返回为0。
/** AndroidBitmap functions result code. */
enum {
/** Operation was successful. */
ANDROID_BITMAP_RESULT_SUCCESS = 0,
/** Bad parameter. */
ANDROID_BITMAP_RESULT_BAD_PARAMETER = -1,
/** JNI exception occured. */
ANDROID_BITMAP_RESULT_JNI_EXCEPTION = -2,
/** Allocation failed. */
ANDROID_BITMAP_RESULT_ALLOCATION_FAILED = -3,
};
一旦返回为0,那么恭喜你,已经成功拿到了Bitmap基本信息。
C++音视频学习资料免费获取方法:关注音视频开发T哥,点击「链接」即可免费获取2023年最新C++音视频开发进阶独家免费学习大礼包!
但是光拿到Bitmap的基本信息还是不够的,还记得上一篇一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(理论篇)提到纹理映射原理的时候说过:
遍历图形中所有的片段,依次通过片段所在的位置坐标定位到其对应在纹理中的纹素,再获取到对应的颜色。
我们知道一张2D图片,其实就是一个二维数组,按照一定的格式,每若干个数组元素其实就是代表一个纹素,所以要拿到对应的纹素,首先要拿到图片的像素二维数组。
所幸的事,jnigraphics库的AndroidBitmap_lockPixels已经帮我们做好这件事了:
/**
* Given a java bitmap object, attempt to lock the pixel address.
* Locking will ensure that the memory for the pixels will not move
* until the unlockPixels call, and ensure that, if the pixels had been
* previously purged, they will have been restored.
*
* If this call succeeds, it must be balanced by a call to
* AndroidBitmap_unlockPixels, after which time the address of the pixels should
* no longer be used.
*
* If this succeeds, *addrPtr will be set to the pixel address. If the call
* fails, addrPtr will be ignored.
*/
int AndroidBitmap_lockPixels(JNIEnv* env, jobject jbitmap, void** addrPtr);
前两个参数不言而喻,最后一个参数就是指向Bitmap像素二维数组的二级指针(如果对于二级指针不太理解,可以看下我之前的博文: 漫谈C语言指针(三) ),简单来说,该方法的作用就是通过一个二级指针指向传过来的Bitmap的像素数组。
注意这个方法的名字带有lock,即它会锁一些东西。锁什么呢?通过方法的注释可知,会锁住像素数据的内存,直到AndroidBitmap_unlockPixels方法调用才解锁。
关于如何处理Bitmap纹素就先看到这,至于拿到的像素数据二级指针要怎么用等会再解答,我们再看看其他的纹理映射逻辑先。
添加纹理坐标
上一篇一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(理论篇)已经提及过纹理坐标的概念:
所以这里我们需要指定纹理坐标,这里指定坐标的意义是指定需要进行纹理映射的那一部分纹理的顶点的坐标点,比如还是下面这张图,就是指定了左边需要映射的三角形的三个顶点在整个纹理中的坐标:
为了简单,我们这里先映射整张图吧:
float vertices[] = {
// 图元顶点坐标 // 纹理坐标
0.5f, 0.5f, 0.0f, 1.0f, 1.0f, // top right
0.5f, -0.5f,0.0f, 1.0f, 0.0f, // bottom right
-0.5f, -0.5f,0.0f, 0.0f, 0.0f, // bottom left
-0.5f, 0.5f, 0.0f, 0.0f, 1.0f // top left
};
这里的纹理坐标就是指定了整张图片四个顶点。(当然,也可以指定只采样图片的一部分,后面会演示)
然后依然像一看就懂的OpenGL ES教程——缓冲对象优化程序(二) 一样使用VBO, VAO, EBO优化程序:
unsigned int indices[] = {
0, 1, 3, // first triangle
1, 2, 3 // second triangle
};
unsigned int VBO, VAO, EBO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
复制代码
着色器逻辑
既然添加了纹理坐标了,根据经验和直觉,着色器是不是就要添加一个变量去接收纹理坐标了呢?
没错,这是毋庸置疑的~
顶点着色器:
#version 300 es
layout (location = 0) in vec4 aPosition;
//新增的接收纹理坐标的变量
layout (location = 1) in vec2 aTexCoord;
//纹理坐标输出给片段着色器使用
out vec2 TexCoord;
void main() {
//直接把传入的坐标值作为传入渲染管线。gl_Position是OpenGL内置的
gl_Position = aPosition;
//纹理坐标传给片段着色器
TexCoord = aTexCoord;
};
这里要新增一个接收纹理坐标的变量aTexCoord,不过,因为采样这个任务还是交给了片段着色器来完成,毕竟着色还是片段着色器要干的活,所以最终还是提供给片段着色器使用,所以又用输出变量TexCoord“送”了出去。
片段着色器:
#version 300 es
precision mediump float;
//新增的接收纹理坐标的变量
in vec2 TexCoord;
out vec4 FragColor;
//传入的纹理
uniform sampler2D ourTexture;
void main() {
//texture方法执行具体的采样
FragColor = texture(ourTexture, TexCoord);
};
这里用同名的TexCoord去接收顶点着色器传过来的纹理坐标。
这里开始出现了一个陌生的新变量类型:sampler2D,看下官网的定义:
A sampler is a set of GLSL variable types. Variables of one of the sampler types must be uniforms or as function parameters. Each sampler in a program represents a single texture of a particular texture type. The type of the sampler corresponds to the type of the texture that can be used by that sampler.
可见它就是代表一个纹理对象,这里sampler2D中的“2D”代表的就是2D纹理。
但是说它代表一个纹理对象其实是不准确的,更准确的是代表一个纹理单元,通过纹理单元去绑定一个纹理对象,从而间接绑定纹理对象。
它只能被uniform修饰或者作为方法参数,这里被uniform修饰也就代表了一帧图像内,这个纹理单元是不会变的,即对应的纹理的图片是不变的。
再看看main函数里面唯一的"宠儿":
FragColor = texture(ourTexture, TexCoord);
它就是传说中重中之重的采样函数了,具体来说就是获取到传入的具体纹理坐标值TexCoord在ourTexture对应的纹理上的纹素的颜色(当然由于不同的过滤模式会导致具体采样颜色的细节不同)。
你可能会问,这里的TexCoord具体的坐标值是多少呢?如果这样问,那你可能8成没看过我之前讲过光栅化插值这个“骚操作”的博文:一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(五),如果看过就知道,在这里,你传入顶点着色器的纹理坐标的那几个值,已经经过光栅化等的处理,把它通过几何关系转化为对应的一个坐标值了。
这么一想,是不是整个流程都非常通畅了呢?
纹理对象配置
所以这里我们先通过AndroidBitmap_getInfo方法获取Bitmap基本信息:
//存储Bitmap基本信息的结构体
AndroidBitmapInfo bmpInfo;
if (AndroidBitmap_getInfo(env, bitmap, &bmpInfo) < 0) {
LOGD("AndroidBitmap_getInfo() failed ! ");
return;
}
然后获取Bitmap像素数组的指针:
void *bmpPixels;
AndroidBitmap_lockPixels(env, bitmap, &bmpPixels);
此时(Bitmap像素数组的指针bmpPixels)枪在手跟我走~
接下来就是配置纹理对象了。
配置什么呢?还记得上一篇博文 讲的纹理环绕和纹理过滤么?不记得的话直接回去看看这篇博文先吧。
前面讲过纹理对象就是一个OpenGL Object,所以它的用法和其他的OpenGL Object是非常相似的,以下是纹理对象的结构图:
Diagram of the contents of a texture object
可以看出,纹理对象由纹理数据存储区+采样参数+纹理参数构成。
根据之前博文 一看就懂的OpenGL ES教程——缓冲对象优化程序(一) 写的,使用一个OpenGL Object的几部曲:
创建对象——绑定对象——处理相关操作逻辑——解绑对象——销毁对象
纹理对象也是如此。
//纹理id
unsigned int texture1;
//创建纹理
glGenTextures(1, &texture1);
//绑定纹理
glBindTexture(GL_TEXTURE_2D, texture1);
绑定纹理,开始具体的采样参数配置(当然不配置也有默认配置,一般最好配置一下为好):
//纹理环绕配置
//横坐标环绕配置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,
GL_REPEAT); // set texture wrapping to GL_REPEAT (default wrapping method)
//纵坐标环绕配置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
//纹理过滤配置
// set texture filtering parameters(配置纹理过滤)
//纹理分辨率大于图元分辨率,即纹理需要被缩小的过滤配置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
//纹理分辨率小于图元分辨率,即纹理需要被放大的过滤配置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
纹理对象的配置是通过glTexParameteri函数实现的:
void glTexParameteri( | GLenum target, |
GLenum pname, | |
GLint param)`; |
target是指定绑定的纹理目标,必须为GL_TEXTURE_1D, GL_TEXTURE_1D_ARRAY, GL_TEXTURE_2D, GL_TEXTURE_2D_ARRAY, GL_TEXTURE_2D_MULTISAMPLE, GL_TEXTURE_2D_MULTISAMPLE_ARRAY, GL_TEXTURE_3D, GL_TEXTURE_CUBE_MAP, GL_TEXTURE_CUBE_MAP_ARRAY, o,GL_TEXTURE_RECTANGLE中的一种。我们映射的是普通的2D纹理,所以使用 GL_TEXTURE_2D。
pname为需要配置具体配置种类。
param为具体的配置的值。
首先是纹理环绕配置,这里通过 首先是纹理环绕配置,这里指定的配置种类为GL_TEXTURE_WRAP_S和GL_TEXTURE_WRAP_T分别表示在s和t轴方向的采样环绕配置。GL_REPEAT表示超过范围重复出现。
s和t轴是什么?看下上篇文章这个熟悉的表示纹理坐标图估计你就懂了~
然后是纹理过滤配置:
//纹理分辨率大于图元分辨率,即纹理需要被缩小的过滤配置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
//纹理分辨率小于图元分辨率,即纹理需要被放大的过滤配置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
GL_TEXTURE_MIN_FILTER和GL_TEXTURE_MAG_FILTER分别代表纹理被缩小和放大的场景。上一篇文章一看就懂的OpenGL ES教程——临摹画手的浪漫之纹理映射(理论篇)已经提到过,当进行采样的时候,纹理的纹素和图元的片段往往不是一样多的(简单理解就是图元面积和纹理图片的面积不一样大),这也就导致了,当纹理映射的时候,我们要做类似将纹理的几个顶点“拉伸”或者“收缩”到和图元顶点贴合在一起的时候,纹理会被放大或者缩小,于是就需要在纹理被放大和缩小2种情况下分别进行采样过滤的配置。
接下来,也就是最重要的一步,那就是将前一步获取到的图片数据传给纹理对象:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bmpInfo.width, bmpInfo.height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, bmpPixels);
看起来有点眼熟的bmpPixels正是前一步获取到的图片像素数组的指针。
glTexImage2D方法的声明为:
void glTexImage2D(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void * data);
target:依然代表纹理目标。
level:这里指的是mipmap的层级,mipmap还没讲到,这里我们暂时只传0。
internalformat:表示纹理存储在GPU中的颜色格式。包括`Base Internal Formats、Sized Internal Formats、Compressed Internal Formats。
最常见的Base Internal Formats有以下格式:
Base Internal Format | RGBA, Depth and Stencil Values | Internal Components |
GL_DEPTH_COMPONENT | Depth | D |
GL_DEPTH_STENCIL | Depth, Stencil | D, S |
GL_RED | Red | R |
GL_RG | Red, Green | R, G |
GL_RGB | Red, Green, Blue | R, G, B |
GL_RGBA | Red, Green, Blue, Alpha | R, G, B, A |
width和height:分别表示纹理图片的宽度和高度,一般要求至少有1024个纹素。
border:这个据说是历史遗留的一个参数,现在固定传0就好。
format:表示传入的纹理像素数据的颜色格式(注意和internalformat的区别)。比如:GL_RED、GL_RG、GL_RGB, GL_BGR、GL_RGBA, GL_BGRA。
type:表示传入的纹理像素数据数组的元素的数据类型,比如GL_UNSIGNED_BYTE, GL_BYTE, GL_UNSIGNED_SHORT, GL_SHORT, GL_UNSIGNED_INT, GL_INT, GL_HALF_FLOAT, GL_FLOAT等等。
data:这就是传入的纹理像素数据的指针了。
还是那句话,OpenGL为了强大的功能性,牺牲了使用的方便性,导致它就像一个憨憨,需要把传入的数据的细枝末节非常唠叨地告诉它,它才知道怎么去解析传入的数据。
这里我们按如下参数来传:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bmpInfo.width, bmpInfo.height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, bmpPixels);
首先映射的是2D纹理,所以target传GL_TEXTURE_2D。然后通过AndroidBitmap_lockPixels方法得到的像素数据格式为RGBA,所以internalformat和format都传GL_RGBA。接下来尺寸数据传从bmpInfo获取的宽高数据,这里像素数据的每个通道由8位组成,即范围为0-255,所以对应的格式为GL_UNSIGNED_BYTE。
然后又是熟悉的解析顶点属性数组数据,分别传入顶点和纹理坐标数据(如果还不清楚具体是怎么解析的,请看系列的前面几篇博文):
//顶点坐标
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void *) 0);
glEnableVertexAttribArray(0);
//纹理坐标
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float),
(void *) (3 * sizeof(float)));
glEnableVertexAttribArray(1);
为了增强大家的学习效果,这次纹理映射的图片就依旧用经典的女神图。
运行看下效果:
额,图片怎么上下颠倒了。。
这是Android平台的OpenGL es一个扎根多年的历史大坑,不要问我出现的原因,我只知道,在Android平台的OpenGL es,纹理坐标的原点是在左上角点(即一般一般情况下(0.0,1.0)点),而不是常见的左下角点,导致我们直接使用传入的纹理坐标会发生上下沿y轴=0.5的直线发生镜面翻转。
在Android平台的OpenGL es,真正的纹理坐标如下图红色文字所示:
所以,这里顶点着色器传给片段着色器的纹理坐标我们需要做一点调整:
#version 300 es
layout (location = 0) in vec4 aPosition;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
void main() {
gl_Position = aPosition;
//纹理坐标要经过上下翻转再传给片段着色器
TexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);;
};
再运行一下:
完美!
聪明的你可能又觉察到一丝不对劲了……
片段着色器中表示纹理(纹理单元)的变量ourTexture我们都没传,咋就能够采样了呢?
原因很简单:OpenGL内部帮我们传了。
如果当前的渲染只需要一个纹理单元的情况下,OpenGL会默认我们使用的是第一个纹理单元,即GL_TEXTURE0。所以片段着色器声明的sampler2D对象就会默认赋值为0,0则代表和GL_TEXTURE0的纹理关联。
而在客户端程序中,我们也并没有制定创建的纹理是属于哪个纹理单元的,所以默认也为第一个纹理单元,即GL_TEXTURE0,所以对该纹理对象的所有操作,都默认为针对即GL_TEXTURE0对应的纹理单元,所以我们的数据其实是默认和片段着色器的ourTexture变量关联上的。
实现多图层混叠
刚才实现的是单个纹理单元的渲染,接下来,我要做一件有趣的事情,就是将石原美里和李英爱的图片混合在一起:
惊不惊喜~~
首先新增一个纹理对象并配置参数和纹理数据:
glGenTextures(1, &texture2);
glBindTexture(GL_TEXTURE_2D, texture2);
// set the texture wrapping parameters
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); // set texture wrapping to GL_REPEAT (default wrapping method)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// set texture filtering parameters
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bmpInfo1.width, bmpInfo1.height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, bmpPixels1);
AndroidBitmap_unlockPixels(env, bitmap1);
上面刚说的我想就不用在这里重复了吧。
这里要增加的步骤是,因为现在是需要2个纹理单元了,所以我们需要手动对纹理单元进行赋值:
//对着色器中的纹理单元变量进行赋值
glUniform1i(glGetUniformLocation(program, "ourTexture"), 0);
glUniform1i(glGetUniformLocation(program, "ourTexture1"), 1);
关于Uniform变量的设置在一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(五)已经提及,这里就不赘述了。
分别对片段着色器中的ourTexture和ourTexture1变量赋值0和1,分别表示GL_TEXTURE0和GL_TEXTURE1纹理单元一一对应。
然后将纹理单元和纹理对象进行绑定:
//将纹理单元和纹理对象进行绑定
//激活纹理单元,下面的绑定就会和当前激活的纹理单元关联上
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);
先使用glActiveTexture方法激活纹理单元,然后根据OpenGL的规则,接下来执行的glBindTexture对应的纹理对象就会和这个激活的纹理单元关联上。
这样子,便完成了纹理对象texture1和着色器中的变量ourTexture、纹理对象texture2和着色器中的变量ourTexture1的绑定。(不得不说这个绑定真绕= =)
片段着色器变成:
#version 300 es
precision mediump float;
in vec2 TexCoord;
out vec4 FragColor;
//传入的纹理
uniform sampler2D ourTexture;
//新增纹理单元
uniform sampler2D ourTexture1;
void main() {
//对2个纹理进行混合
FragColor = mix(texture(ourTexture, TexCoord), texture(ourTexture1, TexCoord), 0.5);
};
mix为OpenGL内置的函数,表示对2个数进行按比例混合叠加,这里就是对当前片段从2纹理采样得到的颜色值进行按照0.5的比例混合。
运行看下:
是不是有点电影转场内味了?是不是妙不可言~
总结
本文主要从代码实践角度详细(盲猜可能网上没有比这个更详细的嘻嘻)讲解为如何进行纹理映射,最后通过将2个纹理进行混合,实现了一个挺有意思的效果。当然不仅仅是混合,这里就可以充分发挥想象力,去干一些灰常有趣的事情,这就是下一篇文章的内容,即开始玩一些奇技淫巧了。
项目代码
opengl-es-study-demo (不断更新中)
参考
纹理 Texture Sampler (GLSL)
作者:半岛铁盒里的猫 链接:https://juejin.cn/post/7155040552353234951
来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
在开发的路上你不是一个人,欢迎加入C++音视频开发交流群「链接」大家庭讨论交流!
本文暂时没有评论,来添加一个吧(●'◡'●)