作者:i_dovelemon

日期:2016 / 07 / 24

主题:HDR, Bloom, Tone mapping, Post-process, Blur

引言

HDR渲染技术,从我学3D图形学开始的时候,打开D3D自带的例子,就向往已久,很想能够自己实现一个这样的效果。这么多年过去了,终于真真的实现了一个基本的HDR渲染流程。今天,我就来向大家介绍下,如何在OpenGL中,实现HDR的渲染。

HDR

在讲解,如何实现HDR之前,先来看下HDR是什么,以及它的作用。首先来看下本次实例将要给大家实现的效果,如下图所示:
上图是一个典型的HDR场景。所谓的HDR,全称是High Dynamic Range,高动态光照范围。我们知道,人类的眼睛能够接受的光照范围非常的大,而当今的显示器,大部分都之能够显示0-255这256个光照明度范围。所以,很多时候,我们通过计算机模拟或者创造出来的图像,都远远低于人类眼睛能够感知到的光照范围。而更大的范围也就意味着,我们能够看到更多的细节,画面细节越多,自然带来的效果就会越好。关于这方面的解释,大家可以参考这篇文章,它详细的讲解了HDR的概念。
单讨论渲染方面,在使用HDR之前,所有的光照计算都在0-255这个范围内进行计算。也就是说,一个场景中所有的明度变化只有这么多。虽然这么多的明度变化,以及能够创建出十分惊人的作品出来了,但是我们没有办法在这样的一个场景中去区分一个灯泡的亮度和太阳的亮度。这两者在现实生活中,太阳的亮度要远远的高于灯泡的亮度。如果不使用HDR的话,他们就只能够是最大的亮度值了,也就是说他们之间没有什么区别。有的人会说,我可以将太阳光定义成最高的,然后将灯泡的定义的比较低啊。可是这样做的话,你就减少的场景中暗的地方的变化范围了,也就是说暗的地方可能都是一样的,一篇漆黑。所以,HDR真真的作用,“让亮的地方更亮,让暗的地方更暗,使画面呈现一种有规律性的明暗变化,使得暗部和亮部的细节都能够展现出来”。

HDR实现原理

在了解了前面一节所说的事实之后,我们很自然的就想到,那就扩大光照计算时使用的范围呗,干嘛要局限在0-255这样的范围里面了。的确,这就是实现HDR最基础的一个操作。我们可以不考虑显示器只能够显示0-255这个范围数据的限制,在所有的定义中,我们在更大的范围里面定义光源的亮度,计算光照,这样,我们就能够得到和现实基本一致的高动态光照范围了。
但是,显示器之能够显示0-255这个范围的限制还是摆在那里,就算我们能够计算出HDR的光照场景出来,最终还是需要靠显示器来显示不是吗?所以,前辈们,就发明了一种称之为Tone mapping的技术。这个技术的主要目的就是将HDR的数据映射到显示器能够显示的LDR(相对HDR)范围里面,并且能够保持HDR数据的明暗过渡(至少在视觉上是这样的)关系,达到我们上面所说的亮的更亮,暗的更暗的效果出来。
所以,通过上面的描述,很自然的就明白HDR的原理分为两个基本的步骤:
1.在HDR范围里面进行场景的渲染,如光源的定义,光照计算等等
2.将渲染好了的HDR场景,进行Tone mapping,最终显示在显示器上。

具体实现(OpenGL)

离屏渲染

很明显,由于显示器只能够显示LDR的数据,所以,我们不能直接把渲染好的HDR场景保存在显示器的缓存中。为此,我们需要一种离屏渲染的计算来完成HDR数据的计算。而这种离屏渲染的方式在OpenGL中,称之为Render to target。关于在OpenGL中,如何实现离屏渲染,我的博客里面《OpenGL&CG技术之Render to target》讲述了如何利用Frame buffer object来实现。
除了前面提到的文章之外,我还要另外补充一些知识给大家,便于更加了解如何实现基于HDR数据的RTT。
首先,由于我们计算出来的场景,最终的结果是HDR数据,也就是说,我们不能够使用传统的GL_UNSIGNED_BYTE的格式来保存我们的图像数据,因为他们之能够保存0-255范围内的数据。所以,我们需要使用OpenGL给我们提供的【浮点纹理】,来保存我们的HDR场景。创建浮点纹理很简单,只要下面这样就可以了:
int32_t tex_id = 0;
glGenTextures(1, reinterpret_cast<GLuint*>(&tex_id));
glBindTexture(GL_TEXTURE_2D, tex_id);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, width, height, 0, GL_RGBA, GL_FLOAT, NULL);
glGenerateMipmap(GL_TEXTURE_2D);

你可以看到,和传统的纹理创建基本一致,只是在调用glTexImage2D的时候,需要传递不一样的参数,告知显卡,我们要保存16位的浮点数据了。

除了这个之外,你还需要知道,FBO可以绑定多个COLOR BUFFER,并且通过一些设置,能够指定它向哪一个COLOR_BUFFER绘制数据。
比如说,你有一个FBO对象g_FBO,你可以为这个g_FBO绑定3个COLOR_BUFFER,分别绑定在GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2上面,当你想要向某个指定的COLOR BUFFER绘制的时候,你只要调用如下的函数就可以了:
glDrawBuffer(GL_COLOR_ATTACHMENTX);

Tone mapping

本篇文章假设你已经会了基本的场景绘制,并且能够在shader中实现光照计算。通过上面的的内容,你能够实现将HDR场景绘制并且保存到纹理贴图中去。那么在执行完毕了这个内容之后,我们需要进行的就是tone mapping了。Tone mapping的计算相对来说比较简单,但是也能够很容易的理解。本篇文章将不探究Tone mapping的实现原理,而仅仅给出实现的方式(主要是我太菜,看不懂他是怎么想到这个鬼方法的!)。
首先,我们来定义一些东西。在前面的原理部分,我们讲述了HDR实际上是对光照亮度的一种描述,那么我们怎么从场景的像素颜色,得到这个亮度信息了。下面的公式是一个经验公式,根据人眼对不同波长的感知能力,分别提取,从而构造一个亮度出来,如下所示:
Lum = R × 0.27 + G × 0.67 + B × 0.06 (1)
上面公式中的Lum表示的就是对应像素(R,G,B)的亮度。通过这个公式,我们能够评价一个像素的亮度。
我们现在知道了如何获取一个像素的亮度,接下来,我们需要计算场景的平均亮度值,因为最终的Tone mapping需要借助这个平均亮度值来实现。计算平均亮度值的数学步骤如下所示:
上面公式的意思是对每一个像素的亮度做ln对数运算,其中为了保证对数运算中的值不为0,给Lum加上一个很小的值0.0001f。既然是平均,那么就要对所有的像素进行这样的对数计算,并且将结果求和。得到这个和之后除以像素的个数,并且再进行一次自然指数的运算。公式为什么是这样的了?我也不知道,感兴趣的话,可以看看文章后面给出的链接,有关于Tone mapping的原始论文。
在有了整个场景的平均亮度之后,我们就要进行实际的Tone mapping运算了,公式如下所示:
Lresult = key * Lum(x,y) / Laverage (2)
是不是很简单,其中的key是一个控制参数,可以让你控制整个场景的整体亮度,这个是个经验参数,根据你对场景的需求,进行调节即可。而最终的Lresult就是经过Tone mapping之后的结果了。
通过上面的计算,我们并不能保证,Tone mapping的计算结果一定在0-255(在shader中归一化到0.0-1.0)的范围里面,为此我们需要对它进行一次归一化操作,这个操作很简单,直接这样就可以了:
Lfinal = Lresult / (1 + Lresult) (3)
Lfinal就是最终我们计算出来的值,这个值也将最终保存在场景的贴图上,用来进行后续的处理。从上面的计算可以看到,只要获取到了整个场景的平均亮度之后,剩下的Tone mapping操作就是对一张场景的HDR贴图进行post-process处理,所以一个Tone mapping就能够简单的通过一个post-process shader来完成。

Bloom

一个完整的HDR渲染器,除了进行HDR的光照计算,进行Tone mapping,往往还需要为场景中的高亮部分添加Bloom效果。这样能够营造出一种相机过曝时的高亮感觉。而Bloom效果,也就是Glare效果,这个效果在本系列的文章《GraphicsLab Project之辉光(Glare, Glow)效果》中详细的进行讨论了。这里不再讲解如何进行Bloom操作。
唯一需要值得注意的是,我们需要提取场景的中的高亮部分。这个所谓的场景中的高亮部分,需要自行定义。一般来收,你需要在进行Tone mapping操作之前,在HDR贴图数据中提取出高亮的部分,形成一张新的高亮贴图,然后对这个高亮贴图进行Bloom操作。并且在最后的Tone mapping中,将Bloom了之后的贴图混合进去,从而营造出高亮曝光的感觉。

实现步骤及结果

1.绘制HDR场景到一张浮点纹理中去,就本例来说,得到的结果如下所示:
上面的贴图是通过gDebugger得到的,纹理为经过处理的HDR贴图。由于显示器之能够显示0.0-1.0的颜色值,所以自动的进行截取,从而形成了上面一圈一圈的波纹状。但是可以看出,每一个波纹里面都是从白色到黑色的渐变,也就是亮度在逐渐的递减。
2.计算场景的平均亮度。这个地方就很有讲究了。不同的HDR实现也主要集中在如何快速高效的求出场景的平均亮度上面。就本次实例来说,为了阐述最原始的概念,将简单粗暴的使用最原始的方法来进行。一方面,这样能够减少很多优化的手段从而造成初学者对HDR的理解困难,另一方面,你只有实际试验过后,才能够知道这样的性能瓶颈在哪里,而不是人云亦云。好了,在这个阶段,我将这个贴图download到CPU这端,主要通过如下的代码完成:
glReadBuffer(GL_COLOR_ATTACHMENT0);
GLvoid* pixel = new GLfloat[4 * g_WindowWidth * g_WindowHeight];
glReadPixels(0, 0, g_WindowWidth, g_WindowHeight, GL_RGBA, GL_FLOAT, pixel);

指定将要读取的COLOR_BUFFER,然后调用glReadPixel来获取纹理数据。获取了整个纹理数据之后,我们就可以通过使用前面的公式来计算整个场景的平均亮度了。只有在实验之后,我才知道调用ln和exp这样的函数,是多么的慢。对整张贴图进行这样的对数,指数运算,的确非常的卡,所以你看,明白了性能的损失了。所以,我就换了另外一种方法来计算平均亮度,也就是简单的将所有亮度值相加,然后除以像素数,这样也能够得到一个平均亮度值,而且这样的计算,似乎速度还可以,至少能够在本实例的情况下,满帧运行。下面就是计算这个平均亮度的完整代码:

    glReadBuffer(GL_COLOR_ATTACHMENT0);GLvoid* pixel = new GLfloat[4 * g_WindowWidth * g_WindowHeight];glReadPixels(0, 0, g_WindowWidth, g_WindowHeight, GL_RGBA, GL_FLOAT, pixel);GLenum error = glGetError();GLfloat* buffer = reinterpret_cast<GLfloat*>(pixel);float lum = 0.0f;for (int32_t i = 0; i < g_WindowHeight; i++) {for (int32_t j = 0; j < g_WindowWidth; j++) {float r = buffer[i * g_WindowWidth * 4 + j * 4 + 0];float g = buffer[i * g_WindowWidth * 4 + j * 4 + 1];float b = buffer[i * g_WindowWidth * 4 + j * 4 + 2];float cur_lum = r * 0.27f + g * 0.67f + b * 0.06f;lum += cur_lum;}}g_RenderTarget->DisableRenderTarget();g_AverageLum = lum / (g_WindowWidth * g_WindowHeight);delete[] pixel;pixel = NULL;

是不是很简单粗暴易于理解了???

3.接下来,我们提取场景中的高亮部分。这个操作也十分的简单,我们只要将在步骤1中获取的HDR原始纹理作为一张贴图,然后绘制一个和屏幕一样大小的矩形。在Shader中,我们计算每一个像素的亮度,如果亮度大于我们指定的高亮阀值,那么就将这个像素绘制出来,如果不是,那么就不绘制,从而就实现了对一个场景高亮部分的提取。这个部分的shader如下所示:
gethighlight.vs
//-----------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[1322600812@qq.com]
// Date: 2016 / 07 / 24
// Brief: Get high light pass through shader
//-----------------------------------------------------------
#version 330in vec2 vertex;
in vec2 texcoord;
out vec2 vs_texcoord;void main() {gl_Position = vec4(vertex, 0.0, 1.0);vs_texcoord = texcoord;
}
gethgihlight.ps
//-----------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[1322600812@qq.com]
// Date: 2016 / 07 / 24
// Brief: Get high light
//-----------------------------------------------------------
#version 330in vec2 vs_texcoord;
out vec4 color;uniform float lum_average;
uniform sampler2D hdr_tex;const float kKey = 0.5;
const float kHighLight = 0.80;void main() {vec3 hdr_color = texture2D(hdr_tex, vs_texcoord).xyz;float lum = hdr_color.x * 0.27 + hdr_color.y * 0.67 + hdr_color.z * 0.06;float lum_after_tonemapping = (kKey * lum) / lum_average;lum_after_tonemapping = lum_after_tonemapping / (1.0 + lum_after_tonemapping);if (lum_after_tonemapping > kHighLight) {color = vec4(hdr_color, 0.5);} else {color = vec4(0.0, 0.0, 0.0, 0.0);}
}

这里要解释下,我在提取高亮部分的时候,实际上是检测进行Tone mapping之后的像素数据的亮度。这样做是为了我们能够通过0.0-1.0这个范围里面,来设定高亮阀值,否则的话,我可不知道该设置成什么样的值,才能够比较准确的提取出来高亮部分。同时你还可能注意到,我这里手动的为每一个高亮的颜色值设置了0.5的alpha值,而为所有的非高亮像素设置了0.0的alpha值。这么做是为了在后面进行blend的时候,能够准确的只和高亮的部分进行blend,而不需要和非高亮的像素进行混合。

这个阶段得到的贴图如下所示:
和前面同样的原因,这里的贴图依然还是HDR的,所以颜色还是从白到黑的变化。整个场景也之后中间的螺环是高亮的部分,因为我给它加上了好高的emission。
4.对高亮的贴图进行bloom操作,从而得到一个bloom之后的高亮贴图,这个操作的shader就是glare的shader,这里再次列出来,以保持文章的整体性:
blur.vs
//--------------------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[1322600812@qq.com]
// Date: 2016 / 06 / 29
// Brief: Gauss blur pass through vertex shader
//--------------------------------------------------------------------
#version 330in vec3 vertex;
in vec2 texcoord;out vec2 vs_texcoord;void main() {gl_Position = vec4(vertex, 1.0);vs_texcoord = texcoord;
}
blurh.ps
//--------------------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[1322600812@qq.com]
// Date: 2016 / 06 / 29
// Brief: Gauss blur horizontal pass shader
//--------------------------------------------------------------------
#version 330in vec2 vs_texcoord;
out vec4 color;uniform sampler2D tex;
uniform float tex_width;uniform float gauss_num[21];void main() {color = texture2D(tex, vs_texcoord) * gauss_num[0];float step = 1.0 / tex_width;for (int i = 1; i < 21; i++) {if (vs_texcoord.x - i * step >= 0.0) {color += texture2D(tex, vec2(vs_texcoord.x - i * step, vs_texcoord.y)) * gauss_num[i];}if (vs_texcoord.x + i * step <= 1.0) {color += texture2D(tex, vec2(vs_texcoord.x + i * step, vs_texcoord.y)) * gauss_num[i];}}
}
blurv.ps
//--------------------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[1322600812@qq.com]
// Date: 2016 / 06 / 29
// Brief: Gauss blur vertical pass shader
//--------------------------------------------------------------------
#version 330in vec2 vs_texcoord;
out vec4 color;uniform sampler2D tex;
uniform float tex_height;uniform float gauss_num[21];void main() {color = texture2D(tex, vs_texcoord) * gauss_num[0];float step = 1.0 / tex_height;for (int i = 0; i <21; i++) {if (vs_texcoord.y - i * step >= 0.0) {color += texture2D(tex, vec2(vs_texcoord.x, vs_texcoord.y - i * step)) * gauss_num[i];}if (vs_texcoord.y + i * step <= 1.0) {color += texture2D(tex, vec2(vs_texcoord.x, vs_texcoord.y + i * step)) * gauss_num[i];}}
}

这个阶段得到的是如下的一张贴图:

5.得到了Bloom高亮贴图之后,拿这个Bloom高亮贴图与进行Tone mapping之后HDR场景贴图进行混合操作,从而得到最终的结果。
tonemap.vs
//-----------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[1322600812@qq.com]
// Date: 2016 / 07 / 24
// Brief: Tone map pass through shader
//-----------------------------------------------------------
#version 330in vec2 vertex;
in vec2 texcoord;
out vec2 vs_texcoord;void main() {gl_Position = vec4(vertex, 0.0, 1.0);vs_texcoord = texcoord;
}
//--------------------------------------------------------
// Declaration: Copyright (c), by i_dovelemon, 2016. All right reserved.
// Author: i_dovelemon[1322600812@qq.com]
// Date: 2016 / 07 / 24
// Brief: Tone mapping the HDR scene
//--------------------------------------------------------
#version 330in vec2 vs_texcoord;
out vec4 color;uniform float lum_average;
uniform sampler2D hdr_tex;
uniform sampler2D bloom_tex;const float key = 0.5;void main() {vec4 hdr_color = texture2D(hdr_tex, vs_texcoord);vec4 bloom_color = texture2D(bloom_tex, vs_texcoord);float lum = hdr_color.x * 0.27 + hdr_color.y * 0.67 + hdr_color.z * 0.06;float lum_after_tonemapping = (key * lum) / lum_average;vec4 blend_color = hdr_color * (1.0 - bloom_color.w) + bloom_color * bloom_color.w;color = blend_color * lum_after_tonemapping;color /= vec4(1.0 + color.x, 1.0 + color.y, 1.0 + color.z, 1.0);
}

最终的结果如下所示:

一个有趣的效果

为了让这个实例的逼格更高一点,我添加了DX SDK中关于HDR的人眼适应过程。这个实现方法很简单,由于我每一帧都会计算下场景的平均亮度,所以,我们可以定义另外一个平均亮度值,这个值用来传递到shader中,构造虚假的场景平均亮度,并且这个亮度值慢慢的逼近场景的平均亮度,这样就能够模拟出先是很亮,然后人眼慢慢适应了之后,慢慢看轻场景的效果。
下面是模拟的代码:
    static float time = 0.0f;float ratio = std::sin(3.1415f * 0.5f * (time / 120.0f));g_UsedLum = 1.0f + (g_AverageLum - 1.0f) * ratio;time = time + 1.0f;if (time > 120.0f) {time = 120.0f;}if (GetKeyState('F') & 0x8000) {time = 0;}

是不是很简单?

整体流程代码一览

void glb_display() {glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);glb_draw_scene_to_texture();glb_calc_average_lum();glb_draw_to_get_highlight_texture();glb_draw_hpass_bloom_highlight_to_texture();glb_draw_vpass_bloom_highlight_to_texture();glb_draw_hdr_scene();glutSwapBuffers();
}

总结

HDR已经是当今引擎的标配了,作为游戏开发人员,有必要了解它。对那些立志于进行引擎相关工作的人来说,更有必要自己实现一个HDR渲染器。

参考文献

[1] http://dev.gameres.com/Program/Visual/3D/HDRTutorial/HDRTutorial.htm HDR渲染器的实现(基于OpenGL)
[2] http://www.nutty.ca/?page_id=352&link=hdr High Dynamic Range(HDR)
[3] http://www.cs.utah.edu/~reinhard/cdrom/tonemap.pdf Photographic Tone Reproduction for Digital Images

GraphicsLab Project之HDR渲染相关推荐

  1. GraphicsLab Project之基于物理的着色系统(Physical based shading) - 基于图像的光照(Image Based Lighting)(Diffuse篇)

    作者:i_dovelemon 日期:2018-01-21 来源:CSDN 主题:PBR, Equrectangular Map, Cube Map, Irradiance Map, HDR Image ...

  2. GraphicsLab Project之辉光(Glare,Glow)效果

    作者:i_dovelemon 日期:2016 / 07 / 02 来源:CSDN 主题:Render to Texture, Post process, Glare, Glow, Multi-pass ...

  3. [转]HDR渲染器的实现(基于OpenGL)

    http://dev.gameres.com/Program/Visual/3D/HDRTutorial/HDRTutorial.htm 作者:何咏(欢迎和大家交流,我的QQ:35574585,Ema ...

  4. OpenGL HDR渲染

    OpenGL HDR渲染 HDR渲染简介 浮点帧缓冲 色调映射 HDR渲染简介 一般来说,当存储在帧缓冲(Framebuffer)中时,亮度和颜色的值是默认被限制在0.0到1.0之间的.这个看起来无辜 ...

  5. 优秀的HDR渲染软件:Topaz Adjust AI for Mac

    Topaz Adjust AI for Mac是Topaz系列中一款专业的HDR渲染软件,topaz adjust ai mac版具备自动曝光调整算法,采用先进的算法来实现独特的曝光效果和色彩调整,T ...

  6. HDR渲染器的实现(基于OpenGL)

    作者:何咏(欢迎和大家交流,我的QQ:35574585,Email:hyestar@126.com) 我的个人网站:http://www.graphixer.com.cn 原文出处:http://ww ...

  7. GraphicsLab Project之基于物理的着色系统(Physical based shading)-直接光照

    作者:idovelemon 日期:2018 / 1 / 1 来源:CSDN 主题:PBS, Microfact Theory, Cook-Torrance 引言 近些年来,基于物理的光照着色系统(Ph ...

  8. GraphicsLab Project之简易贴画系统(Decal System)

    作者:i_dovelemon 日期:2018-08-28 来源:CSDN 主题:Projection Texture Mapping, Decal System 引言 游戏开发过程中有一个非常重要的功 ...

  9. GraphicsLab Project之光照贴图烘焙(一)

    作者:i_dovelemon 来源:CSDN 日期:2018-05-19 主题:Radiosity Algorithm, Global Illumination, Barycentric Coordi ...

最新文章

  1. 下载Ext JS 5.1 gpl版本的方法
  2. Redis学习第三课:Redis Hash类型及操作
  3. html 两个图片并排,HTML – 两个图像并排和响应
  4. 漫步最优化八——梯度信息
  5. 动态规划求解所有字符的组合数
  6. 正则表达式的性能评测
  7. 笔记16(shell编程)
  8. linux 文本编辑 软件管理
  9. java包限制使用时间_给jar包加壳限制使用时间
  10. 数据预处理—2.为什么Lasso回归可以做特征选择(变量挑选)而岭回归做不到呢?
  11. Android开发之来电电话挂断实现
  12. ListView的优化
  13. django常见面试题
  14. 如何做好一个软件测试管理者,高效带好团队呢?
  15. 201771010101 白玛次仁 《2018面向对象程序设计(Java)》第七周学习总结
  16. 【单片机】Android手机USB外接STM32单片机通过ADB实现投屏反向控制的功能
  17. 病原微生物高通量测序:第三节 检测原理
  18. window10 彻底关闭自动更新
  19. 计算机编写代码简介,Vcomputer简介
  20. [H1B/H4] H1B, H4 分别面签成功(广州)分享帖

热门文章

  1. 【yolact_edge】训练自己的yolact_edge模型(并部署在Jetson Xavier上)
  2. 关于关闭谷歌的QUERY_ALL_PACKAGES权限问题
  3. 程序烧录器STLINK_V2CMSIS_DAP_V2制作——DIY方案分享
  4. 微服务容器部署与持续集成(Jenkins)
  5. vue stomp 的使用笔记
  6. 2.15 双创园区:武汉东湖高新区
  7. 有哪些原创的微信公众号推荐?(it技术编程类)
  8. DeeplabV3+训练数据集流程(学习记录)
  9. Sourcetree 无法打开
  10. 二维码扫码登录是什么原理?