ssao算法介绍
一、基本原理介绍
参考资料:
- https://learnopengl-cn.github.io/05 Advanced Lighting/09 SSAO/
- https://www.qiujiawei.com/ssao/
- https://blog.csdn.net/qq_39300235/article/details/102460405
1.1 术语说明
AO(Ambient Occlusion 环境光遮蔽)
:是计算机图形学中的一种着色和渲染技术,描述光线到达物体表面的能力强弱。通过在场景中的点上产生一个标量值来描述由这点向各个方向出射的光线被遮挡的概率 , 这个标量值可以用来表征全局的遮挡效果 , 并为用户提供关于场景中物体之间的位置关系和物体表面的起伏情况的重要视觉信息 。
eg: 在一个房间中,四个角落的光线会远远暗于其他地方。散射光在这种褶皱,孔洞中处通常会难以反射出来,到达我们的眼睛,所以这些地方看着会更加的暗。
计算 AO 可通过在半球面上对可见性函数的积分来得到,公式为:
其中:
- 是 p 点朝向法线方向的半球面上的方向集合
- 是 p 点到其沿ω方向与场景的第一个交点的距离
- 是距离衰减函数衰减函数从 单位距离1 开始衰减并在某个固定距离下衰减到 0
SSAO(Screen Space Ambient Occlusion 屏幕空间环境光遮蔽)
: 可以实时实现环境光遮蔽效果,让画面更‘真实’的一种渲染技术。通过获取像素的深度缓冲
、法线缓冲
以及像素坐标
来计算实现,来近似的表现物体在间接光下产生的阴影。
基于顶点的AO计算需要进行光线与场景的求交运算,所以是十分耗时,所以实际应用中主要使用SSAO算法,SSAO 算法将深度缓存当成场景的一个粗略的近似并用深度比较代替光线求交来简化 AO计算。
下面这幅图展示了在使用和不使用SSAO时场景的不同。可以看到墙角、电话亭后面墙壁的环境光被遮蔽了许多。
1.2 SSAO原理解析
对屏幕空间内每一个像素计算其在三维空间里的位置 p, 执行下列步骤 :
- 在以 p 点为中心、 R 为半径的法线半球体空间内随机地产生若干三维采样点。
这里之所以使用法线半球体,是因为如果使用球体会整体画面会变得灰蒙蒙。
因为针对大部分像素,采样时总有一半的采样点在物体后面,也就是说大部分像素的遮蔽率最高就是0.5,所以整体画面会显得灰蒙蒙。
以下两张图可以形象地说明这个过程:
- 估算每个采样点产生的 AO 情况,具体来说:
a. 对每个采样点做深度测试,判断采样点是否存在遮蔽。
b. 统计所有采样点通过深度测试的比例,该比例就是屏幕空间中该像素粗略的遮蔽率。
说明:当大部分采样点通过深度测试说明p点附近采样点大部分都没有被遮挡,p点大概率不会被遮挡。
网上流行的另一种计算AO的方法是:直接计算采样点在屏幕上的投影点跟 p 点的深度差异,来计算遮蔽情况。如下图所示:
下图大部分投影的的深度值都小于P点深度,说明P点被遮挡的概率较大。
该方法往往会带来自身遮蔽等走样问题,因此使用情况较少。
1.3 SSAO特点
- 独立于场景复杂性,仅和投影后最终的像素有关,和场景中的顶点数三角数没有关系
- 跟传统的AO处理方法相比,不需要预处理,无需加载时间,也无需系统内存中的内存分配,所以更加适用于动态场景。可以作为后处理效果出现。
- 对屏幕上的每个像素以相同的一致方式工作。
- 不需要使用cpu,可以在GPU上完全执行。
- 可以轻松集成到任何现代图形管道中。
1.4 效果展示
左边:未开启SSAO效果(ambient统一设为0.7)
右边:开启SSAO效果(kernalSize=64,radius=1.0)
可以看到开启SSAO效果后,在人体与地面的交界处出现了明显的遮蔽阴影。
二、算法实现过程
SSAO算法整体的流程图如下,共分为四个阶段:
- Gbuffer生成阶段
- SSAO纹理生成阶段
- 模糊纹理生成阶段
- Blinn-Phong光照计算阶段
下文将会逐个介绍每一步的实现细节:
说明:由于SSAO算法在计算过程中需要使用到每个片段的位置
、法线
、等信息,因此该算法非常适合用延迟渲染技术进行实现。
关于延迟渲染技术可以参考LearnOpenGL中延迟着色法及相关章节进行学习。
2.1 Gbuffer生成阶段
这里一共有三点需要注意:
- 第一个注意点:生成Gbuffer时涉及坐标系的选择问题。
具体来说,需要思考G-Buffer的中的位置和法线信息需要设置在哪个坐标系下。
- 如果将Gbuffer中 信息其转换到
view space
下,由于ssao pass
中的所有计算都是在view space
下进行的,处理起来就会比较简单,但是到deferred lighting pass
,光照计算要在world space
下算,position和normal都需要乘以view matrix的逆矩阵恢复到world space, 处理起来会比较麻烦。 (可以在cpu先算好)。
- 如果不在G-Buffer做view变换:那么G-Buffer中信息就是在
world space
坐标系。ssao pass
阶段需要自己处理转换到view space
,而deferred lighting pass则不需要做特殊处理。
整体来讲由于ssao中的处理过程比较复杂,且我们可以提前在CPU中将光源的坐标从world space
转换到view space
中去,这样一来在defered lighting pass
阶段也没有了空间转换问题。因此这里我们选择第一种坐标转换方案。
-
第二个需要注意的点:法线信息由
object space
至view space
时不能直接左乘 进行,具体原因见参考博客关于透视投影变换及相关应用的详细推导第四章法线变换说明部分。 -
第三个需要注意的点:在Gbuffer的片段着色器中我们可以很容易得到屏幕空间的深度信息,但在下个阶段生成SSAO纹理时,所有的计算都是在
view space
中进行的,另一方面屏幕空间中的深度信息是非线性的,可能会存在Zfighting
问题,且无法得到准确的相对深度差,因此,在片段着色器中要将屏幕空间的深度值,转换为’view space’下的线性深度值。
关于Zfighting
问题的及深度转换的公式推导参考博客关于透视投影变换及相关应用的详细推导的1.4节和3.1节。
最终Gubffer的VertexShader和FragmentShader源码如下:
vs 源码:
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
out vec3 FragPos;
out vec2 TexCoords;
out vec3 Normal;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
vec4 viewPos = view * model * vec4(position, 1.0f); //物体空间转观察空间
FragPos = viewPos.xyz;
gl_Position = projection * viewPos; //vs中转换到clip space后opengl会自动帮助转换到NDC坐标
TexCoords = texCoords;
mat4 normalMatrix = view * mat4(transpose(inverse(mat3(model)))); //注意法线转换的特殊之处。
Normal = mat3(normalMatrix) * normal;
}
fs源码:
小trick:为了节省以及减少生成的颜色纹理,这里讲深度信息存储在了颜色纹理的alpha通道。
#version 330 core
//这个布局指示符(Layout Specifier)告诉了OpenGL我们需要渲染到当前的活跃帧缓冲中的哪一个颜色缓冲。
layout (location = 0) out vec4 gPositionDepth;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;
in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;
const float NEAR = 0.1; // 投影矩阵的近平面
const float FAR = 50.0f; // 投影矩阵的远平面
float LinearizeDepth(float depth)
{ //屏幕空间深度转观察空间深度
float z = depth * 2.0 - 1.0; // 回到NDC
return (2.0 * NEAR * FAR) / (FAR + NEAR - z * (FAR - NEAR));
}
void main()
{
/// 储存片段的位置矢量到第一个G缓冲纹理
gPositionDepth.xyz = FragPos;
// 储存线性深度到gPositionDepth的alpha分量,此步完成了屏幕空间的非线性深度,转换到view space中的线性深度
gPositionDepth.a = LinearizeDepth(gl_FragCoord.z);
// 储存法线信息到G缓冲
gNormal = normalize(Normal);
// 存储漫反射颜色
gAlbedoSpec.rgb = vec3(0.95); // Currently all objects have constant albedo color
}
2.2 SSAO纹理生成阶段
显然这是SSAO算法的核心阶段,涉及技术细节相对也比较多,下面会进行分步讲解。
2.2.1 定义单位法向半球及其采样点
如果直接在world or view space对每个表面法线方向生成采样核心非常困难且计算消耗巨大,
因此我们采取在切线空间中生成采样核心,此时法向量将指向正z方向,此时采样点也是在切线空间中,
因此在后续针对采样点做深度测试前还需要记得将其变换到view space(因为整体计算都是在view space中进行)
该过程可通过CPU提前实现:
具体代码如下:
// Sample kernel
std::uniform_real_distribution<GLfloat> randomFloats(0.0, 1.0); // 生成随机浮点数,范围0.0 - 1.0
std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
for (GLuint i = 0; i < 64; ++i)
{
// 生成随机分布采样点,x、y在[-1.0, 1.0]随机,而z在[0.0, 1.0]范围随机
// 确保采样点落在normal向量的同一侧,即z必须大于0
//此步代码产生的采样点,在单位立方体的上半部分,还不是在法线半球内
glm::vec3 sample(randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator));
sample = glm::normalize(sample); //单位化后,采样点落在了法线半球面上
sample *= randomFloats(generator); //分配一个随机距离
GLfloat scale = GLfloat(i) / 64.0; //确保每个采样点长度都不同
scale = lerp(0.1f, 1.0f, scale * scale); //确保采样点尽可能靠近原点(片段)
sample *= scale;
ssaoKernel.push_back(sample);
}
注意这里有一个小trick:采样核心中的采样点时,先在半球范围内获取均匀的采样点,再通过lerp等方法将采样点往圆心偏移。
其中lerp方法定义如下:
GLfloat lerp(GLfloat a, GLfloat b, GLfloat f)
{
return a + f * (b - a);
}
lerp实现效果如下:
2.2.2 定义随机转动向量
在上一步中生成的法线半球采样点将会被应用在每个像素片段上,此时如果采样点数较少,渲染的精度会急剧减少,且会得到一种叫做**波纹(Banding)**的效果。但如果太高计算消耗会过大。因此这里需要引入一个随机转动矩阵帮助我们得到一个效果与计算量相对平很的结果。
注:为了平衡性能,这里的随机转动矩阵只有4x4大小,通过纹理复制平铺的方式铺满真个屏幕空间。因此这里会引入周期噪声问题,针对该问题会在第三步模糊阶段进行处理。
生成4x4朝向切线空间平面法线的随机旋转矩阵代码如下:
std::vector<glm::vec3> ssaoNoise;
for (GLuint i = 0; i < 16; i++)
{
glm::vec3 noise(
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
0.0f);
ssaoNoise.push_back(noise);
}
这里由于采样点都是是沿着正z方向在切线空间内旋转,所以这里将随机转动的z分量也设为0.0,围绕z轴旋转。
接下来将该随机旋转矩阵当做纹理存入纹理缓存中,并注意设定它的封装方法为GL_REPEAT
,以保证它能重复的平铺在整个屏幕空间。
以上两步完成了一些数据的准备工作,真正的算法处理从此步开始:
2.2.3 SSAO顶点着色器
由于ssao算法所需要的数据都已经存在于Gbuffer及相关缓存中,因此vertexShader只需要绘制一个横跨整个屏幕的四边形,以便获取OpenGL生成每个像素点的纹理采样坐标即可。
vs代码如下:
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec2 texCoords;
out vec2 TexCoords;
void main()
{
gl_Position = vec4(position, 1.0f);
TexCoords = texCoords;
}
其中position、texCoords坐标如下:
GLfloat quadVertices[] = {
// Positions // Texture Coords
-1.0f, 1.0f, 0.0f, 0.0f, 1.0f,
-1.0f, -1.0f, 0.0f, 0.0f, 0.0f,
1.0f, 1.0f, 0.0f, 1.0f, 1.0f,
1.0f, -1.0f, 0.0f, 1.0f, 0.0f,
};
2.2.4 SSAO片段着色器
此步终于到了算法的核心部分。
-
根据当前的纹理坐标从Gbuffer中获取位置(fragPos),法线(normal),随机噪声向量(randomVec)。
-
这里在获取随机噪声向量时有一个小trick,由于生成的噪声纹理是 的,要平铺到 的屏幕空间中,就需要水平复制200(800/4)份,竖直复制150(600/4)份,在FragmentShader中得到的纹理采样坐标范围是0-1,这里通过乘以noiseScale将坐标值范围缩放到0-200和0-125,再结合
2.2.2
中提到随机纹理的GL_REPEAT
设置,实际上就实现了重复平铺的效果。这块可能并不好直观理解,建议参考LearnOpenGL纹理部分进行学习。
-
-
利用法线和随机转动向量构建TBN矩阵,用于后续将法线半球采样点由TBN空间转换至观察空间。
- 这里在将法线和随机转动向量构成TBN矩阵时,利用到了Schmidt正交化技术。
- 同时这里有一个小trick,由于我们在得到TBN矩阵的时候切向量采用的是随机转动向量,并不是和目标片元的几何边沿相对齐的,因此,此时的TBN在空间转换的过程自然的完成了随机转动的目的。
-
对半球采样器中的每个样本进行迭代。通过上一步获得的TBN矩阵将样本向量从切线空间转换至观察空间,并通过 得到样本在观察空间位置。
-
接下来要将上步获取的样本在观察空间中的坐标转换至NDC空间,以便我们获得该样本在NDC空间投影点所对应的深度信息。由观察空间转换至NDC空间的推导过程可参考博客关于透视投影变换及相关应用的详细推导第一、二章。其实本质上这可以理解成求取纹理坐标的过程。
-
根据上步获得采样点投影到NDC空间的坐标信息,获取该采样点在NDC空间投影点的实际深度值。这里需要想明白一个点,虽然我们通过坐标变换可以很容易的将观察空间的坐标点投影到NDC空间,但该NDC空间投影点所对应的深度值,未必就对应该采样点的深度。比如:该采样点的前方有一个遮挡物,此时NDC投影点的深度值就是该遮挡物的深度。
关于深度信息,这里还有两点要说明:
- 由于在Gbuffer阶段我们已将深度值转换至观察空间,因此可以直接与采样点的Z值进行比较,这也是为什么一定要转换至观察空间的原因之一。
- 此步获取的深度值是正的,而由于samp.z在观察空间中(z指向屏幕为正)是负值,所以我们对采样的深度乘上-1,方便后续比较。
-
将上一步获得的NDC投影点深度值与该采样点的深度值进行比较,如果前者小于后者说明该采样点前面有遮挡物。
-
统计有多少采样点前面有遮挡物。根据样本遮挡个数与总样本数的比值,我们可以粗略的得到该片段可能被遮挡的概率,继而得出该点的环境光照强度。
以上便是SSAO算法的核心处理过程,但还没有完全结束,这里还有一些优化技巧需要进一步说明:
-
真实场景下物体表面都不是绝对平整的,当采样点处于这样一种临界状态是并不应该对遮蔽产生影响,因此需要增加一个bias来规避临界值问题。
-
理论上只有在取样半径内或附近时才会产生遮蔽影响。而当片段采样点的NDC投影点靠近一个前置物体的边缘,并且深度差远大于采样半径,那么它将会产生错误的遮蔽效果。如下图所示:
为了应对这一问题,我们在深度值比较中增加了范围检测机制
。
smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth ))
首先介绍下smoothstep(val1,val2,val3)函数
,它能够在第一和第二个参数范围内对第三个参数进行平滑插值。其定义如下:
float smoothstep (float edge0, float edge1, float x)
{
if (x < edge0)
return 0;
if (x >= edge1)
return 1;
// Scale/bias into [0..1] range
x = (x - edge0) / (edge1 - edge0);
return x * x * (3 - 2 * x);
}
效果如下图绿色曲线所示:
可以这样直观理解:
- 如果该片段与采样投影点的深度差(
)远远大于我们的采样半径
radius
,那么该采样点实际上不应该对环境光遮蔽产生影响。即: 应该趋于0。 - 如果深度差在采样半径以内,那么该采样点实际上会对环境光遮蔽产生影响,且此时smoothstep输出结果为1。
- 如果深度差略大于检测半径,此时smoothstep将会在0~1之间变动,这样就会带来更加平滑的遮蔽影响。
fs代码如下所示:
#version 330 core
out float FragColor;
in vec2 TexCoords;
uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D texNoise;
uniform vec3 samples[64];
int kernelSize = 64;
float radius = 1;
float bias = 0.025;
//由于生成的噪声纹理是4*4的,要平铺到800*600的屏幕空间中,就需要水平复制200(800/4)份,竖直复制150(600/4)份
// 这里纹理采样坐标范围是0-1,这里通过乘以noiseScale将坐标值范围缩放到0-200和0-125,实际上就实现了重复平铺的效果。
const vec2 noiseScale = vec2(800.0f/4.0f, 600.0f/4.0f);
uniform mat4 projection;
void main()
{
// 1. 根据当前的纹理坐标从Gbuffer中获取位置(fragPos),法线(normal),随机噪声向量(randomVec)
vec3 fragPos = texture(gPositionDepth, TexCoords).xyz;
vec3 normal = texture(gNormal, TexCoords).rgb;
vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
// 2. 利用法线和随机转动向量构建TBN矩阵,用于后续将法线半球采样点由TBN空间转换至观察空间。
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{
//3. 将样本向量从切线空间转换至观察空间,并计算出样本坐标
vec3 samplePos = TBN * samples[i]; // sample vector From tangent to view-space
samplePos = fragPos + samplePos * radius; // 样本在观察空间位置
// 4. 将采样点坐标由观察空间转换至屏幕空间
vec4 offset = vec4(samplePos, 1.0);
offset = projection * offset; // from view to clip-space
offset.xyz /= offset.w; // perspective divide
offset.xyz = offset.xyz * 0.5 + 0.5; // transform to range 0.0 - 1.0
// 5. 获取该采样点在NDC空间投影点的实际深度值
float sampleDepth = -texture(gPositionDepth, offset.xy).w; // 这里取负号是为了方便与fragPos.z方向一致进行比较
//范围检测机制
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
// 6. 根据深度值差异,判断采样点是否存在遮挡
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
}
// 7. 统计有多少采样点前面有遮挡物,得出该点的环境光照强度。
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;
}
至此,我们其实已经可以得到一个SSAO纹理,观察发现图中有网格状的噪声出现,这正是我们下一阶段要解决的问题。
2.3 模糊纹理生成阶段
在2.2.2 节定义随机转动向量时,我们已提过为了平衡性能,随机转动矩阵只有4x4大小,需要不断重复平铺满整屏幕,因此导致图片出现了周期噪声问题。
针对该问题,解决思路比较简单,通过采样周边纹理信息求均值的滤波方法,来过滤噪声问题。
这个阶段VertexShader与上一阶段相同,只是进行了铺平矩形的简单绘制操作。
FS代码如下:
#version 330 core
in vec2 TexCoords;
out float fragColor;
uniform sampler2D ssaoInput;
const int blurSize = 4; // use size of noise texture (4x4)
void main()
{
vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0)); //获取纹理图的宽和高
float result = 0.0;
for (int x = 0; x < blurSize; ++x)
{
for (int y = 0; y < blurSize; ++y)
{
vec2 offset = (vec2(-2.0) + vec2(float(x), float(y))) * texelSize;
result += texture(ssaoInput, TexCoords + offset).r;
}
}
fragColor = result / float(blurSize * blurSize);
}
至此SSAO的实际工作已经全部结束了,后面的操作只是为了更好的实际展示SSAO的效果。
2.4 Blinn-Phong光照计算阶段
这里使用了最简单的Blinn-Phong光照模型结合上一步获取的SSAO纹理来对整个场景进行渲染,原理比较简单。
关于blinn-Phong光照模型可以参考GAMES101第七课以及LearnOpenGL光照部分进行学习。
有两个点需要说明:
-
一般环境光照都是在
world space
下进行的,但由于前面所有的推导都是在view space
下进行,因此为了简化操作,这里在CPU处理阶段预先将光源变换到了view space
下。相关代码如下:// 因为之前的操作都是在view space下计算的、,因此这里提前将光照转到view space glm::vec3 lightPosView = glm::vec3(camera.GetViewMatrix() * glm::vec4(lightPos, 1.0));
-
由于blinn-phone光照模型将环境光设置为了一个常量,因此这里直接用SSAO得到结果替换为环境光照强度。对于其他更复杂的光照模型,可能还需要考虑一些线性融合方式。
FS源码:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;
uniform sampler2D ssao;
struct Light {
vec3 Position;
vec3 Color;
float Linear;
float Quadratic;
};
uniform Light light;
uniform int draw_mode;
void main()
{
// 从G缓冲中提取数据
vec3 FragPos = texture(gPositionDepth, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Diffuse = texture(gAlbedo, TexCoords).rgb;
float AmbientOcclusion = 0.0f;
if (draw_mode == 1){
AmbientOcclusion = texture(ssao, TexCoords).r; // 这里使用了SSAO遮蔽因子
}
else {
AmbientOcclusion = 0.7f;
}
// Then calculate lighting as usual
vec3 ambient = vec3(0.3 * AmbientOcclusion);
vec3 lighting = ambient;
vec3 viewDir = normalize(-FragPos);// 这里都是从gbuffer中采样的数据,处于观察空间,Viewpos 为 (0.0.0)
// Diffuse
vec3 lightDir = normalize(light.Position - FragPos);
vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * light.Color;
// 镜面反射 Specular
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(Normal, halfwayDir), 0.0), 8.0);
vec3 specular = light.Color * spec;
// 衰减 Attenuation
float distance = length(light.Position - FragPos);
float attenuation = 1.0 / (1.0 + light.Linear * distance + light.Quadratic * distance * distance);
diffuse *= attenuation;
specular *= attenuation;
lighting += diffuse + specular;
FragColor = vec4(lighting, 1.0);
}
至此所有的算法实现工作基本就算完成了。
三、AO类算法演化
相关资料:
https://zhuanlan.zhihu.com/p/150431414
3.1 HBAO
HBAO是由NVIDIA 2008年提出作为SSAO升级后的算法。这个算法有着更好的视觉效果,但是它比SSAO算法更加昂贵。HBAO使用一个基于物理的算法。该算法使用深度缓存采样(depth buffer sampling)来近似积分。它不仅仅考虑深度,而且还考虑相对于世界(水平)的表面法向量,来计算表面上的角度和检查多少光照被阻挡。
参考资料:https://www.yuque.com/yikejinyouzi/aau4tk/gs7n7d#ADWmM
这里相关算法还有HBAO+、HDAO等
3.2 GTAO
GTAO 是在HBAO上的进一步演进,主要改进点进一步优化了可见性函数。
https://zhuanlan.zhihu.com/p/145339736
3.3 VXAO
VXAO是一种使用体素(voxel,相当于3D中的像素)来计算AO的方法。
- 点赞
- 收藏
- 关注作者
评论(0)