德者,本也。财者,末也。但行好事,莫问前程。
一、认识纹理
纹理是什么?你可以把它理解成一张贴纸一样的东西,在物体表面贴上图案,纹理的作用就是用来装饰我们的物体模型,就像装修的时候,在家里的墙壁上贴墙纸一样,当然纹理的作用应该远远不止这些。
个人思考:在一些游戏场景中,特别是地图,比如吃鸡这种主机游戏,场景都是用很多相同的,类似3D纹理的立体图形拼出来的,比如一片草地,一片树林,一片房屋,这些3D场景和墙纸及纹理的定义没有本质上的区别,不知广义上把这些“场景贴纸”称为纹理合不合适?大佬可以在评论区告知我或者可以一起讨论。
二、纹理基础
了解纹理基础之前,让我们先来了解一下图像的存储
1、原始图像数据在内存中的存储
图像占用的存储空间 = 图像的高度 * 图像的宽度 * 每个像素点的字节数
2、认识OpenGL图像存储相关函数
1、改变像素存储方式
void glPixelStorei(GLenum pname, GLint param)
2、恢复像素存储方式
void glPxielStoref (GLenum pname, GLFloat param)
参数1:GL_UNPACK_ALIGNMENT,指定OpenGL如何从数据缓存区中解包图像数据;
参数2:表示参数GL_UNPACK_ALIGNMENT设置的值。GL_UNPACK_ALIGNMENT指内存中的每一个像素行起点的排列请求,允许设置为1(byte排列)、2(排列为偶数byte的行)、4(字word排列)8(行从双字节边界开始)
例:glPxielStorei(GL_UNPACK_ALIGNMENT, 1)
3、从颜色缓冲区内容作为像素图直接读取(重要)
void glReadPixels(GLint x, GLint y, GLSizei width, GLSizei height, GLenum format, GLenum type, const void*pixels);
参数1:x,矩形左下角的窗口坐标x值
参数2:y,矩形左下角的窗口坐标y值
参数3:width,矩形的宽,以像素为单位
参数4:height,矩形的高,以像素为单位
参数5:format,OpenGL的像素格式,参照下表1
参数6:type,解释参数pxiels指向的数据,告诉OpenGL使用缓冲区的什么数据类型来存储颜色分量,像素数据的数据类型,参照下表2
参数7:pxiels,指向图形数据的指针
表1、OpenGL的像素格式,常用的是GL_RGB和GL_RGBA 表2、像素数据的数据类型,常用的有GL_UNSIGNED_INT4、载入纹理
void glTexImage1D(GLenum target , GLint level, GLint internalformat, GLsizei width, GLint border, GLenum format, GLenum type, void*data)
void glTexImage2D(GLenum target , GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, void*data)
void glTexImage3D(GLenum target , GLint level, GLint internalformat, GLsizei width, GLsizei height,GLsizei depth, GLint border, GLenum format, GLenum type, void*data)
参数1:target:GL_TEXTURE_2D,GL_TEXTURE_1D, GL_TEXTURE_3D
参数2:level指定所加载的mip贴图层次,一般我们都把这个参数设置为0.
参数3:internalformat:每个纹理单元中存储多少颜色成分。
参数4:width,height,depth参数:指的是加载纹理的宽度、高度、深度;需要注意的是这些数必须是2的整数次方。这个因为OpenGL旧版本上遗留下的一个要求,当然现在已经支持可以不是2的整数次方,但是开发者已经习惯使用2的证书此房去设置这些参数。
参数5:border:允许为纹理贴图制定一个边界宽度
参数6:format、type、data参数与glDrawPxiels函数对于的参数相同。
5、更新纹理
void glTexSubImage1D(GLenum target,GLint level,GLint xOffset, GLint yoffset,GLSizei width, GLenum format, GLenum type,const GLvoid * data)
void glTexSubImage2D(GLenum target,GLint level,GLint xOffset, GLint yoffset,GLSizei width, GLSizei height, GLenum format, GLenum type,const GLvoid * data)
void glTexSubImage3D(GLenum target,GLint level,GLint xOffset, GLint yoffset,GLSizei width, GLSizei height,GLsizei depth, GLenum format, GLenum type,const GLvoid * data)
1D,2D,3D的区别只在于3D比1D多了depth.height,2D只比1D多了height
6、插入替换纹理
void glCopyTexSubImage1D(GLenum target, GLint level, GLint xoffset,GLsizei width,GLsizei height)
void glCopyTexSubImage2D(GLenum target, GLint level, GLint xoffset, GLint yOffset,GLsizei width,GLsizei height)
void glCopyTexSubImage2D(GLenum target, GLint level, GLint xoffset, GLint yOffset,GLint zOffset,GLsizei width,GLsizei height)
1D,2D,3D的区别只在于1D只有xOffset,2D有xOffset yOffset,3D比2D多了zOffset
7、使用颜色缓冲区加载数据,形成新的纹理使用
void glCopyTexImage1D(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y,GLsizei width, GLint border)
void glCopyTexImage2D(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y,GLsizei width, GLsizei height, GLint border)
x,y在颜色缓冲区中指定了开始读取纹理数据的位置:缓冲区里面的数据是源缓冲区通过glReadBuffer设置的
注意:不存在glCopyTexImage3D,因为我们无法从2D颜色缓冲区中获取体积数据
8、使用函数分配纹理对象(重要)
指定纹理对象的数量和指针,(指针指向一个无符号整形数据,由纹理对象标识符填充)
void glGenTexTures(GLsizei n,GLuint * textures);
9、绑定纹理状态(重要)
void glBindTexture(GLenum target, GLunit texture);
参数1:target:GL_TEXTURE_2D,GL_TEXTURE_1D, GL_TEXTURE_3D
参数2:需要绑定的纹理对象
10、删除绑定纹理对象(重要)
void glDeleteTexture(GLsizei n, GLuint * texture);
纹理对象以及纹理对象指针(指针指向一个无符号整形数组,由纹理对象标识符填充)
11、测试纹理对象是否有效
如果texture是一个已经分配空间的纹理对象,那么这个函数会返回GL_TURE,否则会返回GL_FALST
GLboolean glIsTexture(GLuint texture);
12、设置纹理参数
glTextureParameter(GLenum target, GLenum pname, GLFloat param);
glTextureParameter(GLenum target, GLenum pname, GLint param);
glTextureParameter(GLenum target, GLenum pname, GLFloat * param);
glTextureParameter(GLenum target, GLenum pname, GLint *param)
参数1:target,指定这些参数应用哪个纹理模式上,比如GL_TEXTURE_2D,GL_TEXTURE_1D, GL_TEXTURE_3D
参数2:pname,指定需要设置哪个纹理参数
参数3:设定特定的纹理参数的值
13、设置过滤方式(也叫取样)(重点)
图3、邻近过滤1、邻近过滤 GL_NEAREST
邻近过滤是最简单最快速的过滤方法,它总是把最邻近的纹理单元取到纹理坐标中。
图4、线性过滤2、线性过滤GL_LINEAR
线性过滤会把这个纹理坐标周围的纹理单元加权平均值应用到这个纹理坐标中。周围坐标的纹理单元距离越近,则权值越大
一般建议纹理缩小用邻近过滤,纹理放大用线性过滤,当然你也可以随意搭配
glTexParameter(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
glTexParameter(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
14、设置环绕方式
图5、环绕方式 图6、环绕方式图示glTextParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAR_S, GL_CLAMP_TO_EDGE);
glTextParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAR_T, GL_CLAMP_TO_EDGE)
参数1: GL_TEXTURE_1D、GL_TEXTURE_2D、GL_TEXTURE_3D
参数2:GL_TEXTURE_WRAP_S、GL_TEXTURE_T、GL_TEXTURE_R,针对s,t,r坐标
S 、T、 R 坐标系对应着世界坐标系的X, Y, Z
参数3:GL_REPEAT、GL_CLAMP、GL_CLAMP_TO_EDGE、GL_CLAMP_TO_BORDER
GL_REPEAT:OpenGL 在纹理坐标超过1.0的⽅向上对纹理进⾏重复;
GL_CLAMP:所需的纹理单元取⾃纹理边界或TEXTURE_BORDER_COLOR.
GL_CLAMP_TO_EDGE环绕模式强制对范围之外的纹理坐标沿着合法的纹理单元的最后一行或者最后⼀列来进行采样。
GL_CLAMP_TO_BORDER:在纹理坐标在0.0到1.0范围之外的只使⽤边界纹理单元。边界纹理单元是作为围绕基本图像的额外的⾏和列列,并与基本纹理图像⼀起加载的
三、金字塔绘制及纹理填充
图7,金字塔模型是由6个三角形组成的(侧面4个三角形,底部两个三角形拼接) 图8金字塔实现并填充纹理思维导图(图像转载自简书用户CC老师_HelloCoder)
1、主函数中设置环境及注册函数
intmain(intargc,char* argv[])
{
gltSetWorkingDirectory(argv[0]);
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_STENCIL);
glutInitWindowSize(800, 600);
glutCreateWindow("Pyramid");
glutReshapeFunc(ChangeSize);
glutSpecialFunc(SpecialKeys);
glutDisplayFunc(RenderScene);
GLenum err = glewInit();
if(GLEW_OK!= err) {
fprintf(stderr,"GLEW Error: %s\n",glewGetErrorString(err));
return1;
}
SetupRC();
glutMainLoop();
ShutdownRC();//重要,纹理绑定后需要进行解绑
return 0;
}
2、定义全局变量
GLShaderManager shaderManager;//着色器程序
GLMatrixStack modelViewMatrix;//模型视图矩阵
GLMatrixStack projectionMatrix;
GLFrame cameraFrame;//观察者坐标
GLFrame objectFrame;//对象坐标
GLFrustum viewFrustum;//
GLBatch pyramidBatch;//金字塔
GLuint textureID;//纹理变量,一般使用无符号整型
GLGeometryTransform transformPipeline;
M3DMatrix44f shadowMatrix;
3、开始设置环境
voidSetupRC()
{
//1.初始化着色器,设置清屏颜色
glClearColor(0.7f, 0.7f, 0.7f, 1.0f );
shaderManager.InitializeStockShaders();
//2.开启深度测试
glEnable(GL_DEPTH_TEST);
//3.分配纹理对象
// 参数1:纹理对象个数,金字塔的5个面,共6个三角形,但是用的是同一个纹理,所以纹理对象个数为1。
//参数2:纹理对象指针,之前定义过了纹理对象,直接拿下来取地址就行了
glGenTextures(1, &textureID);
//4、绑定纹理状态
//参数1:纹理状态2D 参数2:纹理对象
glBindTexture(GL_TEXTURE_2D, textureID);
//将TGA文件加载为2D纹理。
//参数1:纹理文件名称
//参数2&参数3:需要缩小&放大的过滤器
//参数4:纹理坐标环绕模式
LoadTGATexture("stone.tga", GL_LINEAR_MIPMAP_NEAREST, GL_LINEAR, GL_CLAMP_TO_EDGE);
//5.创造金字塔pyramidBatch
MakePyramid(pyramidBatch);
//6.调整观察者位置
/**相机frame MoveForward(平移)
参数1:Z,深度(屏幕到图形的Z轴距离)
*/
cameraFrame.MoveForward(-10);
}
4、渲染场景
步骤一:设置场景
voidRenderScene(void)
{
//1.颜色值&光源位置
staticGLfloatvLightPos [] = {1.0f,1.0f,0.0f};
staticGLfloatvWhite [] = {1.0f,1.0f,1.0f,1.0f};
//2.清理缓冲区
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
//3.当前模型视图压栈
modelViewMatrix.PushMatrix();
//添加照相机矩阵
M3DMatrix44fmCamera;
//从camraFrame中获取一个4*4的矩阵
cameraFrame.GetCameraMatrix(mCamera);
//矩阵乘以矩阵堆栈顶部矩阵,相乘结果存储到堆栈的顶部 将照相机矩阵 与 当前模型矩阵相乘 压入栈顶
modelViewMatrix.MultMatrix(mCamera);
//创建mObjectFrame矩阵
M3DMatrix44fmObjectFrame;
//从objectFrame中获取矩阵,objectFrame保存的是特殊键位的变换矩阵
objectFrame.GetMatrix(mObjectFrame);
//矩阵乘以矩阵堆栈顶部矩阵,相乘结果存储到堆栈的顶部 将世界变换矩阵 与 当前模型矩阵相乘 压入栈顶
modelViewMatrix.MultMatrix(mObjectFrame);
//4.绑定纹理,因为我们的项目中只有一个纹理。如果有多个纹理。绑定纹理很重要
glBindTexture(GL_TEXTURE_2D, textureID);
/*5.点光源着色器
参数1:GLT_SHADER_TEXTURE_POINT_LIGHT_DIFF(着色器标签)
参数2:模型视图矩阵
参数3:投影矩阵
参数4:视点坐标系中的光源位置
参数5:基本漫反射颜色
参数6:图形颜色(用纹理就不需要设置颜色。设置为0)
*/
shaderManager.UseStockShader(GLT_SHADER_TEXTURE_POINT_LIGHT_DIFF,
transformPipeline.GetModelViewMatrix(),
transformPipeline.GetProjectionMatrix(),
vLightPos, vWhite,0);
//pyramidBatch 绘制
pyramidBatch.Draw();
//模型视图出栈,恢复矩阵(push一次就要pop一次,push和pop成对存在)
modelViewMatrix.PopMatrix();
//6.交换缓存区
glutSwapBuffers();
}
步骤二:绘制金字塔
voidMakePyramid(GLBatch& pyramidBatch)
{
/*1、通过pyramidBatch组建三角形批次
参数1:类型
参数2:顶点数
参数3:这个批次中将会应用1个纹理
注意:如果不写这个参数,默认为0。
*/
pyramidBatch.Begin(GL_TRIANGLES,18,1);
/***前情导入
1)设置法线
void Normal3f(GLfloat x, GLfloat y, GLfloat z);
Normal3f:添加一个表面法线(法线坐标 与 Vertex顶点坐标中的Y轴一致)
表面法线是有方向的向量,代表表面或者顶点面对的方向(相反的方向)。在多数的关照模式下是必须使用。后面的课程会详细来讲法线的应用
pyramidBatch.Normal3f(X,Y,Z);
2)设置纹理坐标
void MultiTexCoord2f(GLuint texture, GLclampf s, GLclampf t);
参数1:texture,纹理层次,对于使用存储着色器来进行渲染,设置为0
参数2:s:对应顶点坐标中的x坐标
参数3:t:对应顶点坐标中的y
(s,t,r,q对应顶点坐标的x,y,z,w)
pyramidBatch.MultiTexCoord2f(0,s,t);
3)void Vertex3f(GLfloat x, GLfloat y, GLfloat z);
void Vertex3fv(M3DVector3f vVertex);
向三角形批次类添加顶点数据(x,y,z);
pyramidBatch.Vertex3f(-1.0f, -1.0f, -1.0f);
4)获取从三点找到一个法线坐标(三点确定一个面)
void m3dFindNormal(result,point1, point2,point3);
参数1:结果
参数2-4:3个顶点数据
*/
//塔顶
M3DVector3fvApex = {0.0f,1.0f,0.0f};
M3DVector3fvFrontLeft = { -1.0f, -1.0f,1.0f};
M3DVector3fvFrontRight = {1.0f, -1.0f,1.0f};
M3DVector3fvBackLeft = { -1.0f, -1.0f, -1.0f};
M3DVector3fvBackRight = {1.0f, -1.0f, -1.0f};
M3DVector3f n;
//金字塔底部
//底部的四边形 = 三角形X + 三角形Y
//三角形X = (vBackLeft,vBackRight,vFrontRight)
//1.找到三角形X 法线
m3dFindNormal(n, vBackLeft, vBackRight, vFrontRight);
//vBackLeft
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.0f,0.0f);
pyramidBatch.Vertex3fv(vBackLeft);
//vBackRight
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,1.0f,0.0f);
pyramidBatch.Vertex3fv(vBackRight);
//vFrontRight
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,1.0f,1.0f);
pyramidBatch.Vertex3fv(vFrontRight);
//三角形Y =(vFrontLeft,vBackLeft,vFrontRight)
//1.找到三角形X 法线
m3dFindNormal(n, vFrontLeft, vBackLeft, vFrontRight);
//vFrontLeft
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.0f,1.0f);
pyramidBatch.Vertex3fv(vFrontLeft);
//vBackLeft
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.0f,0.0f);
pyramidBatch.Vertex3fv(vBackLeft);
//vFrontRight
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,1.0f,1.0f);
pyramidBatch.Vertex3fv(vFrontRight);
// 金字塔前面
//三角形:(Apex,vFrontLeft,vFrontRight)
m3dFindNormal(n, vApex, vFrontLeft, vFrontRight);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.5f,1.0f);
pyramidBatch.Vertex3fv(vApex);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.0f,0.0f);
pyramidBatch.Vertex3fv(vFrontLeft);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,1.0f,0.0f);
pyramidBatch.Vertex3fv(vFrontRight);
//金字塔左边
//三角形:(vApex, vBackLeft, vFrontLeft)
m3dFindNormal(n, vApex, vBackLeft, vFrontLeft);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.5f,1.0f);
pyramidBatch.Vertex3fv(vApex);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,1.0f,0.0f);
pyramidBatch.Vertex3fv(vBackLeft);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.0f,0.0f);
pyramidBatch.Vertex3fv(vFrontLeft);
//金字塔右边
//三角形:(vApex, vFrontRight, vBackRight)
m3dFindNormal(n, vApex, vFrontRight, vBackRight);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.5f,1.0f);
pyramidBatch.Vertex3fv(vApex);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,1.0f,0.0f);
pyramidBatch.Vertex3fv(vFrontRight);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.0f,0.0f);
pyramidBatch.Vertex3fv(vBackRight);
//金字塔后边
//三角形:(vApex, vBackRight, vBackLeft)
m3dFindNormal(n, vApex, vBackRight, vBackLeft);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.5f,1.0f);
pyramidBatch.Vertex3fv(vApex);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,0.0f,0.0f);
pyramidBatch.Vertex3fv(vBackRight);
pyramidBatch.Normal3fv(n);
pyramidBatch.MultiTexCoord2f(0,1.0f,0.0f);
pyramidBatch.Vertex3fv(vBackLeft);
//结束批次设置
pyramidBatch.End();
}
步骤三:自定义函数 ,从TGA文件加载2D纹理。
boolLoadTGATexture(constchar*szFileName,GLenumminFilter,GLenummagFilter,GLenumwrapMode)
{
GLbyte*pBits;
intnWidth, nHeight, nComponents;
GLenumeFormat;
//1、读纹理位,读取像素
//参数1:纹理文件名称
//参数2:文件宽度地址
//参数3:文件高度地址
//参数4:文件组件地址
//参数5:文件格式地址
//返回值:pBits,指向图像数据的指针
pBits =gltReadTGABits(szFileName, &nWidth, &nHeight, &nComponents, &eFormat);
if(pBits ==NULL)
return false;
//2、设置纹理参数
//参数1:纹理维度
//参数2:为S/T坐标设置模式
//参数3:wrapMode,环绕模式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrapMode);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrapMode);
//参数1:纹理维度
//参数2:线性过滤
//参数3:wrapMode,环绕模式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, minFilter);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, magFilter);
//3.载入纹理
//参数1:纹理维度
//参数2:mip贴图层次
//参数3:纹理单元存储的颜色成分(从读取像素图是获得)
//参数4:加载纹理宽
//参数5:加载纹理高
//参数6:加载纹理的深度
//参数7:像素数据的数据类型(GL_UNSIGNED_BYTE,每个颜色分量都是一个8位无符号整数)
//参数8:指向纹理图像数据的指针
glTexImage2D(GL_TEXTURE_2D,0, nComponents, nWidth, nHeight,0,
eFormat,GL_UNSIGNED_BYTE, pBits);
//使用完毕释放pBits
free(pBits);
//4.加载Mip,纹理生成所有的Mip层
//参数:GL_TEXTURE_1D、GL_TEXTURE_2D、GL_TEXTURE_3D
glGenerateMipmap(GL_TEXTURE_2D);
return true;
}
5、窗口改变监听
voidChangeSize(intw,inth)
{
//1.设置视口
glViewport(0,0, w, h);
//2.创建投影矩阵
viewFrustum.SetPerspective(35.0f, float(w) / float(h), 1.0f, 500.0f);
//viewFrustum.GetProjectionMatrix() 获取viewFrustum投影矩阵
//并将其加载到投影矩阵堆栈上
projectionMatrix.LoadMatrix(viewFrustum.GetProjectionMatrix());
//3.设置变换管道以使用两个矩阵堆栈(变换矩阵modelViewMatrix ,投影矩阵projectionMatrix)
//初始化GLGeometryTransform 的实例transformPipeline.通过将它的内部指针设置为模型视图矩阵堆栈 和 投影矩阵堆栈实例,来完成初始化
//当然这个操作也可以在SetupRC 函数中完成,但是在窗口大小改变时或者窗口创建时设置它们并没有坏处。而且这样可以一次性完成矩阵和管线的设置。
transformPipeline.SetMatrixStacks(modelViewMatrix, projectionMatrix);
}
6、特殊键位操作
voidSpecialKeys(intkey,intx,inty)
{
if(key ==GLUT_KEY_UP)
objectFrame.RotateWorld(m3dDegToRad(-5.0f), 1.0f, 0.0f, 0.0f);
if(key == GLUT_KEY_DOWN)
objectFrame.RotateWorld(m3dDegToRad(5.0f), 1.0f, 0.0f, 0.0f);
if(key == GLUT_KEY_LEFT)
objectFrame.RotateWorld(m3dDegToRad(-5.0f), 0.0f, 1.0f, 0.0f);
if(key == GLUT_KEY_RIGHT)
objectFrame.RotateWorld(m3dDegToRad(5.0f), 0.0f, 1.0f, 0.0f);
glutPostRedisplay();//视口改变,更新窗口
}
7、最后需要清除绑定的纹理
voidShutdownRC(void)
{
glDeleteTextures(1, &textureID);
}
8、最终效果图
图8、最终效果图[溪浣双鲤的技术摸爬滚打之路](https://www.jianshu.com/p/3fbecd65faae)
网友评论