干货| 学习 HDR 和 Bloom 技术
共 21025字,需浏览 43分钟
·
2021-09-25 15:49
HDR 和 Bloom 技术
面对一项新技术,我们首先要解决的一个问题是,我们为什要用它,它有什么好处?在真实世界中,我们的光照强度范围非常广,近乎是无限的。
但在计算机中,我们只能把强度范围压缩到[0,1]之间,这对我们来说就非常不公平,凭什么像太阳光那种比手电筒要强上几十万倍的强度和手电筒一样要被限制到1,这太扯淡了。要是手电筒是1的话,那么太阳光就是要几十万,这才能有接近真实世界的效果。
于是,HDR就粉墨登场了。
HDR(High Dynamic Range,高动态范围)技术,让我们能用超过1.0的数据表示颜色值。我们可以把颜色的格式设置成16位浮点数或者32位浮点数的方式来扩大颜色值的范围。
到目前为止,我们用的都是LDR(低动态范围),所以我们所有的渲染都被限制了。
HDR技术对我们来说非常有用,举个简单的例子,如果场景中有很多光源,每个都照在同一个物体上,这个物体的亮度很快就会超过1.0。
如果我们强制将它限制到1.0,我们就会失去这个物体上的细节信息,例如纹理状况。所以,为了配合HDR,我们还需要独特的色调映射(tone mapping)算法来有选择地显示物体高光部分的细节,低光部分的细节,还是都显示,达到类似这样的效果:
随着曝光度的增加,原本场景中高光部分(窗户玻璃)的细节逐渐消失,低光部分(楼梯扶手背部)的细节逐渐显现。这就是色调映射算法的威力。
Bloom,中文名叫泛光(或者眩光,怎么顺怎么来),是用来模拟光源那种发光或发热感觉的技术。举个例子:
有Bloom技术的支持,场景的显示效果提升了一个档次。
发光发热就是要有这种效果,这种一眼看上去就是光源的效果,不像左边的图那么干巴巴的,像是没电了一样。
之所以把Bloom和HDR放在一起讲,是因为很多人都把HDR和Bloom搞混了,认为他们就是一个东西,其实不然。我可以只使用HDR来渲染场景,也可以只使用Bloom来渲染,两者是互相独立互不影响的。当然,只使用一种技术产生的效果不好,这个回头再说。
这次我们会先创建一个只使用HDR的场景,然后创建一个将HDR和Bloom融合起来的场景看效果。
使用HDR
要使用HDR非常简单,我们只需要将帧缓存的颜色缓存格式设置成16或者32位浮点数就行。
这样,当我们渲染场景到帧缓存时,OpenGL就不会把颜色值截断到1.0,我们也就保留了场景的HDR信息。
你可以设置默认帧缓存的颜色缓存,也可以像我一样新建一个帧缓存,附加一个16位浮点数格式的颜色缓存。当然了,推荐使用自己新建帧缓存,后面我们有大用。具体的设置代码你可以参考这里:
//颜色缓存
unsigned int colorBuffer;
glGenTextures(1, &colorBuffer);
glBindTexture(GL_TEXTURE_2D, colorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
//深度缓存
unsigned int rboDepth;
glGenRenderbuffers(1, &rboDepth);
glBindRenderbuffer(GL_RENDERBUFFER, rboDepth);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, SCR_WIDTH, SCR_HEIGHT);
//帧缓存
unsigned int hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorBuffer, 0);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rboDepth);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "帧缓存未初始化完毕!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
上述代码中,需要重点关注的只有一行,就是
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);。
你可以看到,我们对纹理的内部格式做了一些小改动,把原本我们熟悉的GL_RGBA改成了GL_RGBA16F,这就是告诉OpenGL我们的纹理内部格式要16位浮点数,你不要把它截断了,我有大用。就是这么简单!
构造一个场景
为了看看HDR对场景的影响,我们先要来创造一个可以看出不同的场景。
这里,我们假想自己处于一个隧道之中,隧道一端有一盏非常亮的灯(亮度大约是200),然后,在隧道里面零星地放着几盏小灯,这些灯远没有隧道尽头的灯亮(亮度大约是0.1),我们就在隧道的另一端看这个隧道的样子。
要模拟隧道,我们可以把一个标准的立方体在某一个轴(比如说z轴)上进行放大,再设置一些光源的位置和颜色:
//光源信息
//位置
std::vector<glm::vec3> lightPositions;
lightPositions.push_back(glm::vec3(0.0f, 0.0f, 49.5f)); // 后面的光
lightPositions.push_back(glm::vec3(-1.4f, -1.9f, 9.0f));
lightPositions.push_back(glm::vec3(0.0f, -1.8f, 4.0f));
lightPositions.push_back(glm::vec3(0.8f, -1.7f, 6.0f));
//颜色
std::vector<glm::vec3> lightColors;
lightColors.push_back(glm::vec3(200.0f, 200.0f, 200.0f)); //亮到不像话的光
lightColors.push_back(glm::vec3(0.1f, 0.0f, 0.0f));
lightColors.push_back(glm::vec3(0.0f, 0.0f, 0.2f));
lightColors.push_back(glm::vec3(0.0f, 0.1f, 0.0f));
完成之后,添加一些代码,将场景渲染到之前我们创建的帧缓存上,然后显示出来,代码结构大概是这个样子:
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (GLfloat)SCR_WIDTH / (GLfloat)SCR_HEIGHT, 0.1f, 100.0f);
glm::mat4 view = camera.GetViewMatrix();
shader.use();
shader.setMat4("projection", glm::value_ptr(projection));
shader.setMat4("view", glm::value_ptr(view));
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, woodTexture);
for (int i = 0; i < lightPositions.size(); ++i) {
shader.setVec3("lights[" + std::to_string(i) + "].Position", lightPositions[i]);
shader.setVec3("lights[" + std::to_string(i) + "].Color", lightColors[i]);
}
shader.setVec3("viewPos", camera.Position);
//渲染隧道
glm::mat4 model = glm::mat4();
model = glm::translate(model, glm::vec3(0.0f, 0.0f, 25.0));
model = glm::scale(model, glm::vec3(2.5f, 2.5f, 27.5f));
shader.setMat4("model", glm::value_ptr(model));
shader.setInt("inverse_normals", true);
renderCube();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
//2、渲染到四边形中
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
hdrShader.use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, colorBuffer);
renderQuad();
没啥好说的,代码都非常直观,接下来是着色器部分。顶点着色器非常简单,只需要接收两个值的输入,再将这两个值输出就行了,这两个值分别是位置和纹理坐标。
重点在片元着色器!片元着色器的框架是这样:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D hdrBuffer;
//一些设置
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
//一些其他的操作
...
vec3 result = pow(hdrColor, vec3(1.0 / gamma));
FragColor = vec4(result, 1.0);
}
这是一个简单的片元着色器,非常简单。说实话,你可以直接用这个着色器看看没有经过特殊的色调映射算法时显示的效果,效果也不会差。
这里要注意的是,在计算颜色的时候,我们运用了gamma校正。这就要求我们必须在加载图片的时候指定SRGB格式,然后,在渲染场景时使用二次项的衰减系数。
接下来,我们来到HDR技术最重要的一个环节:色调映射(tone mapping)。
色调映射(tone Mapping)
对HDR技术来说,最重要的就是色调映射算法。
当我们有了一个场景的HDR信息后,我们就无可避免地面临这样一个选择,是更多地表现高光部分的细节,还是低光部分的细节,或者是两个部分细节都表现出来?
你可能觉得,这还用想吗,当然是两个部分的细节都要。这个你还真不能急,因为如果你把两个部分的细节都显示出来了,整个场景就会显的非常不真实,有一种置身魔法世界的玄幻感,如果你要显示的是人的话,可能会有一种顶着光环的上帝的既视感,很诡异。
所以,我们基本上都是在高光和低光之间做出选择,这就衍生出我们最常用的一个映射算法:曝光度运算算法。
上面这个公式就是算法计算公式,其中hdr表示我们得到的hdr颜色信息,1.0表示LDR的亮度上限值,e是自然底数。将这个公式翻译成代码就是:
result = vec3(1.0) - exp(-hdrColor * exposure);
加上这行核心代码之后,就可以运行了。且慢动手,我们还没对这个exposure变量赋值呢。为了方便观察不同exposure值的效果,我们要将这个变量设置成uniform,然后main文件中添加按键控制功能,具体的代码不用我多说,相信各位都已经非常熟悉了。
好了,我们来运行一下看看效果:
可以看到,随着曝光度越来越低,高光部分的细节越来越清晰,当然,低光部分的细节就越来越模糊了。如果这是一个真实的游戏场景,这个效果会更加震撼!
Bloom(泛光)
要实现Bloom特效,我们首先就要知道需要在哪些地方实现,换句话说,我们需要知道光源的位置。这个步骤,配合上HDR技术,我们可以非常容易的实现。
通常,光源都是场景中最亮的东西,我们可以把它设置成一个大于1.0的数值,然后,在渲染场景的时候,在片元着色器中添加判断:如果当前片元的颜色值大于1.0,我们就认为它是光源,将其渲染出来;如果不是,则把它的颜色设置成黑色。
这样,我们就能得到一个张只有光源的渲染图,接着就可以通过这张图来实现模糊效果进而实现Bloom特效。
高斯模糊
实现Bloom特效中最难也是最重要的一个步骤,就是对光源场景进行高斯模糊。之前我们也实现过高斯模糊效果,是在帧缓存的文章中,我们使用了一个核矩阵来实现整个场景的模糊效果,但是这种模糊效果太low了,这次我们会用另一种方法,叫做双通道高斯模糊(two-pass Gaussian blur)。
从模糊的原理角度来讲,所有的模糊都基于这样一点事实:当前片元的最终颜色值是当前片元的颜色以及周围的颜色共同作用的结果。各种模糊效果不同点仅仅是所有这些起作用的片元对最终颜色值起多大的作用而已。
在帧缓存一文中,我们认为,当前片元颜色权重为4/16=0.25,与之相邻的四个片元颜色的权重为2/16=0.125,角落里的四个片元是1/16=0.0625。将这些颜色值合并起来之后就是最终的颜色值。双通道高斯模糊采样的权重与计算方式和这个不同,来看下面这张图:
我们认为,当前片元颜色值是由横竖两条线上的片元颜色值决定的,其权重由近到远分别是:0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162。并且,为了得到更好的模糊效果,我们会对同一张图进行不止一次的双通道高斯模糊操作。
好,原理的内容已经弄清了,下面就实现这个效果。
实现Bloom特效
先来整理一下实现的步骤:
使用两个帧缓存或者用给一个帧缓存附加两个颜色缓存用来分别渲染场景和光源。 对光源图进行5次双通道高斯模糊操作,将结果保存到一个新的帧缓存中 将场景和经过高斯模糊操作的光源图进行合并 对合并的图进行色调映射处理,形成最终的效果图输出
流程很明确,关键在于如何实现!
1.渲染场景和光源
用两个帧缓存渲染场景和光源这种操作实在是太low了,我们怎么能如此不思进取,当然要选择后一种方法:用一个帧缓存的两个不同的颜色缓存来渲染。
要做到这一点,我们就需要对原本绑定颜色缓存的流程做一点改动:
/** 物体本身和光源场景帧缓存 */
unsigned int hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
//创建两个颜色缓存
unsigned int colorBuffers[2];
glGenTextures(2, colorBuffers);
for (int i = 0; i < 2; ++i) {
glBindTexture(GL_TEXTURE_2D, colorBuffers[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_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);
//附加到帧缓存
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0);
}
//创建深度缓存(渲染缓存)
unsigned int rboDepth;
glGenRenderbuffers(1, &rboDepth);
glBindRenderbuffer(GL_RENDERBUFFER, rboDepth);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, SCR_WIDTH, SCR_HEIGHT);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rboDepth);
//告诉OpenGL这个帧缓存要用到哪些颜色缓存
unsigned int attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, attachments);
//检查帧缓存是否设置成功
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "帧缓存设置失败,请检查代码!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
/** 物体本身和光源场景帧缓存-结束 */
使用2个颜色缓存的时候要注意两点:
创建两个颜色缓存,附加到帧缓存的时候分别指定成GL_COLOR_ATTACHMENT0和GL_COLOR_ATTACHMENT1。这一步,上面的代码通过一个循环,然后GL_COLOR_ATTACHMENT0+i的方式来设置。 告诉OpenGL渲染这个帧缓存的时候,需要渲染2个颜色缓存。这一步,是通过调用glDrawBuffers函数,指定数量并且指定要渲染的颜色缓存数组来实现的。剩下的代码都是平常的创建帧缓存的代码,不多解释。
在片元着色器中,我们也要指定两个缓存输出的颜色是什么,这个操作和指定顶点着色器输入的操作非常类似,我们来看:
//片元着色器
#version 330 core
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
...
void main() {
// 检查结果值是否高于某个门槛,如果高于就渲染到光源颜色缓存中
float brightness = dot(result, vec3(0.2126, 0.7152, 0.0722));
if(brightness > 1.0)
BrightColor = vec4(result, 1.0);
else
BrightColor = vec4(0.0, 0.0, 0.0, 1.0);
FragColor = vec4(result, 1.0);
}
如你所见,我们要用layout的方法指定缓存0和缓存1对应的颜色,然后在主函数中对这些颜色赋值,这样我们就能得到相应的场景效果了,是不是很简单?我们把门槛值设置成1.0,凡是超过这个值的我们都认为是发光区域(不仅仅是光源才超过,有些亮的部分也会超过),将颜色输出到缓存1中,正常的场景输出到缓存0中。
编译运行之后,你应该看到这样的场景:
2.进行高斯模糊操作
按照我们之前分析的原理,我们可以使用一些小技巧巧妙地实现高斯模糊。
我们先对整个场景进行横向的高斯模糊渲染,然后再进行纵向的高斯模糊渲染,重复5次达到效果。
为了能实现这个目标,我们要创建两个帧缓存来回渲染。将进行横向渲染的场景保存到缓存1中,再用1进行纵向的渲染到缓存2,这才算是完成了一次高斯模糊渲染。然后,继续用缓存2中的图片渲染到缓存1中,一直这样来回渲染,直到完成5次完整的双通道高斯模糊渲染。
听上去很带劲是不是,实现起来也很带劲:
/** 乒乓帧缓存 */
unsigned int pingpongFBO[2];
unsigned int pingpongColorBuffer[2];
glGenFramebuffers(2, pingpongFBO);
glGenTextures(2, pingpongColorBuffer);
for (int i = 0; i < 2; ++i) {
glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[i]);
glBindTexture(GL_TEXTURE_2D, pingpongColorBuffer[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_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);
glFramebufferTexture2D(
GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, pingpongColorBuffer[i], 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "帧缓存准备失败!" << std::endl;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
/** 乒乓帧缓存-结束 */
创建两个帧缓存,来回渲染的过程很像打乒乓球,所以我们形象地将它命名为乒乓帧缓存。由于我们不需要深度信息,所以两个帧缓存只要附加上颜色缓存就可以了。
//进行高斯模糊
bool horizontal = true, first_iteration = true;
shaderBlur.use();
for (int i = 0; i < two_passGaussianBlurCount * 2; ++i) {
glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]);
shaderBlur.setInt("horizontal", horizontal);
glBindTexture(GL_TEXTURE_2D, first_iteration ? colorBuffers[1] : pingpongColorBuffer[!horizontal]);
renderQuad();
horizontal = !horizontal;
if (first_iteration)
first_iteration = false;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
上面这段代码是用来进行来回渲染用的。首先,在第一次迭代的时候,我们要用光源场景图进行首次渲染。然后,在之后的迭代中用的都是上一次完成高斯模糊渲染之后的图。直到我们进行了10次循环。horizontal变量用来设置当前是进行水平模糊计算还是垂直模糊计算,在片元着色器中有这个uniform接受主函数的控制:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D image;
uniform bool horizontal;
uniform float weight[5] = float[](0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162);
void main() {
vec2 tex_offset = 1.0 / textureSize(image, 0); //每个像素的尺寸
vec3 result = texture(image, TexCoords).rgb * weight[0];
if (horizontal) {
for (int i = 0; i < 5; ++i) {
//左右两个方向上都要进行采样
result += texture(image, TexCoords + vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
result += texture(image, TexCoords - vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
}
}
else {
for (int i = 0; i < 5; ++i) {
//上下两个方向上都要进行采样
result += texture(image, TexCoords + vec2(0.0, tex_offset.y * i)).rgb * weight[i];
result += texture(image, TexCoords - vec2(0.0, tex_offset.y * i)).rgb * weight[i];
}
}
FragColor = vec4 (result, 1.0);
}
片元着色器的代码应当说是相当直观的:我们首先计算了每个片元的位置偏移值,采用的方式是获取当前纹理图尺寸然后取倒数。
接着,result变量初始化为当前片元颜色乘上权重数组中的首元素。当然这不是必须的,你也可以直接初始化为0,或者乘上其他的数值当成是权重。
接着,接收horizontal变量的控制,如果是true则进行水平方向的模糊计算,要注意当前片元的左右两边都需要计算,所以每次循环需要进行两次计算。
实现之后,我们就可以来看看高斯模糊之后的效果了:
这个效果就非常明显了,不像之前的核效果那样,就是稍微模糊了一点,不仔细看还看不出来。
合并场景和模糊图,进行色调映射
合并场景和模糊图的方式也非常简单,进行色调映射的方式更加简单,把前面HDR例子里色调映射的算法复制过来就好了。
先来看主函数中合并的代码:
shaderBloomFinal.use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, colorBuffers[0]);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, pingpongColorBuffer[!horizontal]);
shaderBloomFinal.setInt("bloom", 1);
shaderBloomFinal.setFloat("exposure", 1.0);
renderQuad();
就是用一个新的着色器来处理两张图,然后输出,没啥花头。片元着色器的代码也很简单:
#version 330 core
out vec4 FragColor;
in vec2 TexCoord;
uniform sampler2D image;
uniform sampler2D imageBlur;
uniform bool bloom;
uniform float exposure;
void main() {
const float gamma = 2.2;
vec3 hdrColor = texture(image, TexCoord).rgb;
vec3 bloomColor = texture(imageBlur, TexCoord).rgb;
if (bloom)
hdrColor += bloomColor; //添加融合
//色调映射
vec3 result = vec3 (1.0) - exp(-hdrColor * exposure);
//进行gamma校正
result = pow(result, vec3 (1.0 / gamma));
FragColor = vec4(result, 1.0);
}
如果启用了Bloom,就将两张图的对应片元颜色相加,然后进行色调映射,再加上gamma校正,形成最终的颜色值输出。最终的效果如下所示:
总结
好,来个总结吧。这次我们学了HDR和Bloom的知识,HDR本质上是用一个更广的范围表示颜色值,然后通过色调映射算法显示出来。Bloom则是通过高斯模糊的方法将场景中发光物体表现地像是在发光发热的样子。
HDR的核心在于色调映射算法,这次我们只介绍了曝光度的算法,公式是:
高斯模糊的算法是对某一个片元进行水平和垂直的两次权重计算,重复几次,得到最终的结果。原理上非常简单,记住也非常容易,经常使用就可以了。
原文链接:https://www.jianshu.com/p/6206707c265d
技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。
推荐阅读:
觉得不错,点个在看呗~