零、前言
GLSL (OpenGL Shading Language) 是一种 面向过程 高级着色语言,用于图形渲染编程。
使用 GLSL 进行编写顶点着色器和片元着色器,编写两种着色器的语法上没有差异。
一、OpenGL ES 着色器语言简介
GLSL 是基于 C/C++ 基本语法和流程控制的语言,但不支持:双精度浮点数(double)、字节型(byte)、短整型(short)、长整型(long),而且取消联合体(union)、枚举(enum) 。增加了向量(vec) 、矩阵(mat)类型。
二、着色器的结构
以上篇《Hello OpenGL ES! 启航!》中三角形“顶点着色器”和“片元着色器”为例:
顶点着色器
#version 300 es
uniform mat4 uMVPMatrix;
in vec3 aPosition;
in vec4 aColor;
out vec4 vColor;
void main() {
gl_Position = uMVPMatrix * vec4(aPosition, 1.0);
vColor = aColor;
}
片元着色器
#version 300 es
precision mediump float;
in vec4 vColor;
out vec4 fragColor;
void main() {
fragColor = vColor;
}
通过以上代码,可以知道一个着色器程序包括了:
- glsl 版本声明。 如果是 2.0 的版本,可以不用书写该注释。
- 全局变量声明。 这里包括输入和输出变量,着色器内部使用的全局变量等。
- 自定义函数。 我们需要在着色器中调用的函数,上面的着色器很简单所以就没有这一部分(后续会分享如何定义一个着色器函数)。被调用的着色器函数需要在调用函数之前,且定义的函数不可以进行递归调用。
- main 函数。 着色器的入口函数,每个着色器都必须定义,管线会调用该函数驱动逻辑。
二、数据类型
GLSL ES 数据类型:标量、向量、矩阵、采样器、结构体、数组、空类型。
1、标量
描述: 只具有大小,不具有方向的值。例如:质量、体积。
包括:
- bool:布尔型,glsl 的流程控制只能使用布尔类型的值作为表达式。
bool b = true;
- int:有符号 32 位整型。
int a = 15; // 默认为有符号整型
- uint:无符号 32 位整型,需要在整数后增加 "u" 或 "U" ,否则字面常量为有符号整数。
uint b = 3u; // 无符号的类型需要使用 u 或 U 结尾
GLSL ES 整数可以使用十进制、八进制(数字前用数字 0 )、十六进制(数字前用 0x )。
registry.khronos.org/OpenGL/spec… 4.1.3 Integers 有进行描述
// 有符号整数
int signedDec = 123;
int signedOct = 0177;
int signedHex = 0xFF;
// 无符号整数
uint unsignedDec = 123u;
uint unsignedOct = 0177u;
uint unsignedHex = 0xFFu;
- float:浮点型,32 位单精度,可以使用十进制和指数形式表示。
float g = 2.0;
float j = 3e2;
GLSL ES 没有 double 类型,所以不管是否添加 "f" 后缀,小数点形式的数字默认都被视为 float 类型,例如:1.0 和 1.0f 都是 float 类型。
虽然不加 "f" 后缀也能工作,但为了代码一致性或移植性,可以考虑添加 "f" ,添加 "f" 不会导致错误,只是在 GLSL ES 中会显得有些冗余。
2、向量
向量是由相同类型的标量组成,从上一小节知道基本类型分为:bool、int、uint、float 四种,所以向量有以下的类型。
2-1、类型
| 向量类型 | 说明 |
|---|---|
| vec2 | 包含了 2 个浮点数的向量 |
| vec3 | 包含了 3 个浮点数的向量 |
| vec4 | 包含了 4 个浮点数的向量 |
| ivec2 | 包含了 2 个整数的向量 |
| ivec3 | 包含了 3 个整数的向量 |
| ivec4 | 包含了 4 个整数的向量 |
| bvec2 | 包含了 2 个布尔数的向量 |
| bvec3 | 包含了 3 个布尔数的向量 |
| bvec4 | 包含了 4 个布尔数的向量 |
| uvec2 | 包含了 2 个无符号整数的向量 |
| uvec3 | 包含了 3 个无符号整数的向量 |
| uvec4 | 包含了 4 个无符号整数的向量 |
2-2、使用方法
向量在不同场景使用时,表示着不同的含义。可以当作三种场景使用:颜色、位置、纹理坐标,具体每个分量的名称如下所示。
| 用法 | 4 个分量名 | 3 个分量名 | 2 个分量名 | 使用 |
|---|---|---|---|---|
| 颜色 | r、g、b、a | r、g、b | r、g | aColor.r 或者 aColor[0] |
| 位置 | x、y、z、w | x、y、z | x、y | aPosition.x 或者 aPosition[0] |
| 纹理坐标 | s、t、p、q | s、t、p | s、t | aTexColor.s 或者 aTexColor[0] |
GLSL 的向量由硬件原生支持,进行向量运算时是各分量并行一次完成(n 个分量只需要一次计算),效率很高。
2-3、使用技巧
2-3-1、向量构造函数
- 如果向量的构造函数内只有一个标量值,则该向量的所有分量都等于该值。
// 所有分量都为 5.0 的二维向量,等同于 vec2(5.0, 5.0)
vec2 v2 = vec2(5.0);
// 所有分量都为 5.0 的三维向量,等同于 vec3(5.0, 5.0, 5.0)
vec3 v3 = vec3(5.0);
// 所有分量都为 5.0 的四维向量,等同于 vec4(5.0, 5.0, 5.0, 5.0)
vec4 v4 = vec4(5.0);
- 如果向量的构造函数内有多个标量或者向量参数,向量的分量则由左向右依次被赋值。如果声明的向量维度小于构造器中向量的总维度,则会舍弃多余的分量。如果声明的向量维度大于构造器中向量的总维度,则会导致着色器编译失败。
// 正确:从左到右赋值。
vec3 v1 = vec3(1.0, 2.0, 3.0); // v1 = (1.0, 2.0, 3.0)
vec3 v2 = vec3(vec2(1.0, 2.0), 3.0); // v2 = (1.0, 2.0, 3.0)
// 正确:多余分量被忽略。
vec2 v3 = vec2(1.0, 2.0, 3.0); // v3 = (1.0, 2.0),3.0被忽略
vec2 v4 = vec2(vec3(1.0, 2.0, 3.0)); // v4 = (1.0, 2.0),3.0被忽略
// 编译错误,缺少第三个分量。
vec3 v5 = vec3(1.0, 2.0);
- 如果向量构造函数的参数与声明变量的类型不相符,则会将参数转换为相应的数据类型。
vec3 v = vec3(1, 2, 3.0); // 合法,会转为 (1.0, 2.0, 3.0)
2-3-2、向量赋值
以赋值表达式中的 “=” 为界,其左侧称之为 L 值,右侧称之为 R 值。
进行混合选择时,R 值可以使用一个向量的各个分量任意地组合以及重复,而 L 值则不能有任何的重复分量,但可以改变分量顺序,而且分量名称必须是同一名称组。
vec4 color = vec4(0.7, 0.1, 0.5, 1.0); // 声明一个 vec4 类型的向量 color
vec3 temp = color.agb; // 相当于获取到一个向量(1.0,0.1,0.5)赋值给 temp
vec4 tempL = color.aabb; // 相当于获取到一个向量(1.0,1.0,0.5,0.5)赋值给 tempL
vec3 tempLL; // 声明一个 3 维向量 tempLL
tempLL.grb = color.aab; // 对向量 tempLL 的 3 个分量赋值
3、矩阵
3-1、类型
| 矩阵类型 | 说明 |
|---|---|
| mat2 | 2×2 的浮点数矩阵 |
| mat3 | 3×3 的浮点数矩阵 |
| mat4 | 4×4 的浮点数矩阵 |
| mat2×2 | 2×2 的浮点数矩阵 |
| mat2×3 | 2×3 的浮点数矩阵 |
| mat2×4 | 2×4 的浮点数矩阵 |
| mat3×2 | 3×2 的浮点数矩阵 |
| mat3×3 | 3×3 的浮点数矩阵 |
| mat3×4 | 3×4 的浮点数矩阵 |
| mat4×2 | 4×2 的浮点数矩阵 |
| mat4×3 | 4×3 的浮点数矩阵 |
| mat4×4 | 4×4 的浮点数矩阵 |
第一个数字为列数、第二个数字为行数
3-2、矩阵访问
GLSL 中,矩阵可以看作是多个列向量的组成。
- mat3 可以看作 3 个 vec3 向量组成。
- 如果 matrix 为一个 mat4,可以使用 matrix[2] 取到该矩阵的第 3 列,是一个 vec4 。使用 matrix[2][2] 取得第 3 列的向量的第 3 个分量,其为一个 float 。
3-3、矩阵使用
3-3-1、构造规则
- 如果矩阵的构造函数内只有一个标量值,那么矩阵的对角线上的分量都等于该值,其余值为 0。
- 矩阵可以由多个向量构造而成。
- 矩阵可以由大量的标量值构成,矩阵的分量由左向右依次被赋值,并且以列方向进行赋值。
- 初始化时矩阵 M1 的行列数 (N×N) 小于构造器中矩阵 M2 的行列数 (M×M) 时(即 N<M ),矩阵 M1 的元素值为矩阵 M2 左上角 N×N 个对应元素的值。可以结合下图理解。
- 初始化时矩阵 M1 的行列数 (N×M) 与构造器中矩阵 M2 的行列数 (P×Q) 不同,且 P 和 Q 之间的最大值大于 N 和 M 之间的最大值时 (假设 M1 为 mat2×4 ,M2 为 mat4×2 ) ,矩阵 M1 左上角 N×N 个元素的值为矩阵 M2 左上角 N×N 个元素的值,矩阵 M1 的其他行的元素值为 0。可以结合下图理解。
- 初始化时矩阵 M1 的行列数 (N×N) 大于构造器中矩阵 M2 的行列数 (M×M) 时(即 N>M ),矩阵 M1 左上角 M×M 个元素的值为矩阵 M2 的对应元素值,矩阵 M1 右下角剩余对角线元素的值为 1,矩阵 M1 剩余其他的元素值为 0。可以结合下图理解。
3-3-2、使用
float a = 6.3;
float b = 11.4;
float c = 12.5;
vec3 va = vec3(2.3, 2.5, 3.8);
vec3 vb = vec3(a, b, c); // vb = (6.3, 11.4, 12.5)
vec3 vc = vec3(vb.x, va.y, 14.4); // vc = (6.3, 2.5, 14.4)
mat3 ma = mat3(1.0, 2.0, 3.0, // ma = (1.0, 4.0, 7.0,
4.0, 5.0, 6.0, // 2.0, 5.0, 8.0,
7.0, 8.0, c); // 3.0, 6.0, 12.5)
mat3 mb = mat3(va, vb, vc); // mb = (2.3, 6.3, 6.3,
// 2.5, 11.4, 2.5,
// 3.8, 12.5, 14.4)
mat3 mc = mat3(va, vb, 1.0, 2.0, 3.0); // mc = (2.3, 6.3, 1.0,
// 2.5, 11.4, 2.0,
// 3.8, 12.5, 3.0)
mat3 md = mat3(2.0) ; // md = (2.0, 0.0, 0.0,
// 0.0, 2.0, 0.0,
// 0.0, 0.0, 2.0)
mat4x4 me = mat4x4(3.0); // me = (3.0,0.0,0.0,0.0,
// 0.0,3.0,0.0,0.0,
// 0.0,0.0,3.0,0.0,
// 0.0,0.0,0.0,3.0)
mat3x3 mf = mat3x3(me); // mf = (3.0,0.0,0.0,
// 0.0,3.0,0.0,
// 0.0,0.0,3.0)
vec2 vd = vec2(a, b); // vd = (6.3, 11.4)
mat4x2 mg = mat4x2(vd, vd, vd, vd); // mg = (6.3, 6.3, 6.3, 6.3,
// 11.4, 11.4, 11.4, 11.4)
mat2x3 mh = mat2x3(mg); // mh = (6.3, 6.3,
// 11.4,11.4,
// 0.0, 0.0)
mat4x4 mj = mat4x4(mf); // mj = (3.0,0.0,0.0,0.0,
// 0.0,3.0,0.0,0.0,
// 0.0,0.0,3.0,0.0,
// 0.0,0.0,0.0,1.0)
4、采样器
描述: 用于纹理采样,一个采样器变量代表一幅纹理或是一套纹理贴图。
| 采样器类型 | 描述 |
|---|---|
| sampler2D | 用于访问浮点型的二维纹理 |
| sampler3D | 用于访问浮点型的三维纹理 |
| samplerCube | 用于访问浮点型的立方贴图纹理 |
| samplerCubeShadow | 用于访问浮点型的立方阴影纹理 |
| sampler2DShadow | 用于访问浮点型的二维阴影纹理 |
| sampler2DArray | 用于访问浮点型的 2D 纹理数组 |
| sampler2DArrayShadow | 用于访问浮点型的 2D 阴影纹理数组 |
| isampler2D | 用于访问整型的二维纹理 |
| isampler3D | 用于访问整型的三维纹理 |
| isamplerCube | 用于访问整型的立方贴图纹理 |
| isampler2DArray | 用于访问整型的 2D 纹理数组 |
| usampler2D | 用于访问无符号整型的二维纹理 |
| usampler3D | 用于访问无符号整型的三维纹理 |
| usamplerCube | 用于访问无符号整型的立方贴图纹理 |
| usampler2DArray | 用于访问无符号整型的 2D 纹理数组 |
值得注意:
- 采样器变量不能在着色器中进行初始化,只能通过 OpenGL API 函数在 CPU 端绑定纹理。
- 采样器变量用 uniform 限定符修饰,从宿主语言(如 C/C++、Java、Kotlin 等)接收传递进着色器的值。
- 采样器变量可以用作函数的参数,但是作为函数参数时不可以使用
out或inout修饰符来修饰。
5、结构体
5-1、描述
和 C/C++ 语言类似,用 struct 进行定义,使用方式和 C/C++ 一样。
5-2、用法
struct info{
vec3 color;
vec3 position;
vec2 textureCoor;
};
// 声明一个 info 类型的变量 CubeInfo
info CubeInfo;
5-3、构造函数
构造函数内的每一个值都会按顺序赋给结构体内相应的成员,要求每一个值的类型都要与结构体内的成员类型相匹配。
struct light{ // 定义结构体 light
float intensity; // 声明 float 型成员
vec3 position; // 声明 vec3 型成员
};
light lightVar = light(2.0, vec3(1.0, 2.0, 3.0)); // 创建 light 结构体的实例
6、数组
6-1、描述
和大多数语言一样,GLSL 也支持数组类型,使用的规则也类似。
6-2、构造数组
// 第一种,包含 20 个 vec3 的数组
vec3 position[20];
// 第二种,定义同时指定内容
float x[] = float[2](1.0, 2.0);
float y[] = float[](1.0, 1.0, 1.0);
值得注意:
OpenGL ES 3.0 的 GLSL 只支持一维数组。
7、空类型
描述: 仅仅用于声明不返回任何值函数的类型。
void main(){}
三、变量声明、作用域
因为 GLSL 是基于 C/C++ 语言语法,所以很多语法上有相同的地方,在变量声明、作用域与 C/C++ 语言类似。
- 变量可以在着色器中任何地方进行声明。
- 变量因作用域可以分为局部变量和全局变量。
vec4 lightPosition = vec4(1.0); // 声明了全局变量 lightPosition ,作用域为整个着色器。
void fun() {
int count = 4; // 声明了局部变量 count,作用域为函数 `fun`
}
四、变量的特殊规则
“全局的输入变量”、“一致变量” 以及 “输出变量” 在声明时一定不能进行初始化。
in float angleSpan; // 不能对 “全局输入变量” 进行初始化
uniform int count; // 不能对 “一致变量” 进行初始化
out vec3 position; // 不能对 “全局的输出变量” 进行初始化
着色器中应少使用字面常量,多用常量代替字面量,可以提高代码可维护性和性能,避免重复计算,例如下面代码。
// 使用常量代替字面常量
const float PI = 3.14159;
const vec3 LIGHT_COLOR = vec3(1.0, 1.0, 1.0);
void main() {
// 使用常量进行计算
float radius = 2.0 * PI;
vec3 finalColor = vec3(0.5) * LIGHT_COLOR;
}
五、运算符的特殊表现
- 向量以及矩阵中使用
++或--,则向量或矩阵的每个元素都加 1 或者减 1。
vec3 v = vec3(1.0, 2.0, 3.0);
++v; // v = (2.0, 3.0, 4.0)
v--; // v = (1.0, 2.0, 3.0)
mat2 m = mat2(1.0, 2.0, 3.0, 4.0);
m++; // m = [2.0, 3.0, 4.0, 5.0]
- 向量和矩阵相乘,矩阵和矩阵相乘则遵循线性代数的乘法规则。
mat3 m = mat3 ( // m = [ 1.0, 4.0, 7.0,
1.0, 2.0, 3.0, // 2.0, 5.0, 8.0,
4.0, 5.0, 6.0, // 3.0, 6.0, 9.0 ]
7.0, 8.0, 9.0
);
vec3 v = vec3(10.0, 20.0, 30.0); // v = [ 10.0,
// 20.0,
// 30.0 ]
vec3 result = m * v; // result = (1.0*10.0 + 4.0*20.0 + 7.0*30.0,
// 2.0*10.0 + 5.0*20.0 + 8.0*30.0,
// 3.0*10.0 + 6.0*20.0 + 9.0*30.0)
// result = (300.0, 360.0, 420.0)
- 两个向量进行 “乘除加减” 运算时,按照两个向量的分量进行 “乘除加减”,最终得到一个新向量。
vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = vec3(4.0, 5.0, 6.0);
vec3 sum = a + b; // sum = (5.0, 7.0, 9.0)
vec3 diff = a - b; // diff = (-3.0, -3.0, -3.0)
vec3 product = a * b; // product = (4.0, 10.0, 18.0)
vec3 division = a / b; // division = (0.25, 0.4, 0.5)
- 关系运算符(<、>、<= 、>=)只能用在浮点数或整数标量的操作中,通过关系运算符的运算将产生一个布尔型的值。
float a = 3.0;
float b = 5.0;
bool result1 = a < b; // true
bool result2 = a >= b; // false
// 错误用法示例:
// vec2 v1 = vec2(1.0, 2.0);
// vec2 v2 = vec2(2.0, 1.0);
// bool res = v1 < v2; // 编译错误! 不能直接比较向量
- 比较两个向量中的每一个分量的大小结果,需要使用内置函数
lessThan、lessThanEqual、greaterThan、greaterThanEqual,这些函数会返回一个向量,向量中则为比较结果。
vec3 v1 = vec3(1.0, 5.0, 3.0);
vec3 v2 = vec3(2.0, 3.0, 3.0);
bvec3 less = lessThan(v1, v2); // (true, false, false)
bvec3 greater = greaterThan(v1, v2); // (false, true, false)
bvec3 lessEqual = lessThanEqual(v1, v2); // (true, false, true)
bvec3 greaterEqual = greaterThanEqual(v1, v2); // (false, true, true)
- 等于运算符(==、!=)可以用在任何类型数据的操作中,操作将对左右两个操作数值的每一个分量分别进行比较,得出一个布尔型的值,说明左右两个操作数是否完全相等。
vec3 v1 = vec3(1.0, 2.0, 3.0);
vec3 v2 = vec3(1.0, 2.0, 3.0);
vec3 v3 = vec3(1.0, 2.0, 4.0);
bool isEqual1 = (v1 == v2); // true,所有分量都相等
bool isEqual2 = (v1 == v3); // false,至少有一个分量不相等
bool notEqual = (v1 != v3); // true,至少有一个分量不相等
- 想要得到两个向量中的每一个元素是否相等的结果,则可以调用内置函数
equal和notEqual。
vec3 v1 = vec3(1.0, 2.0, 3.0);
vec3 v2 = vec3(1.0, 2.0, 3.0);
vec3 v3 = vec3(1.0, 2.0, 4.0);
// 分量级比较
bvec3 eq = equal(v1, v3); // (true, true, false)
bvec3 neq = notEqual(v1, v3); // (false, false, true)
- 逻辑运算符包括 “与(&&)”、“或(||)”、“非(!)”、“异或(^^)” 4 种操作类型,只可以用在类型为布尔标量的表达式中,不可以用在矩阵中。
bool a = true;
bool b = false;
bool andResult = a && b; // false
bool orResult = a || b; // true
bool notResult = !a; // false
bool xorResult = a ^^ b; // true (仅当一个为 true 另一个为 false 时结果为 true)
// 错误用法示例:
// vec2 bv1 = vec2(true, false);
// vec2 bv2 = vec2(false, true);
// vec2 andVec = bv1 && bv2; // 编译错误! 不能用于向量
- 位运算符包括取 “反(~)”、“左移(<<)”、“右移(>>)”、“与(&)”、“或(|)”、“异或(^)” 六种操作类型,这些操作符只适用于 有符号或者无符号的整型标量 或者 整型向量 的类型。其中 “与”、“或”、“异或” 要求左右两边的操作数必须是相同长度的整型量。
// 整型标量的位运算
int a = 5; // 二进制:101
int b = 3; // 二进制:011
int bitwiseNot = ~a; // 二进制:...11111010
int leftShift = a << 1; // 二进制:1010,值为10
int rightShift = a >> 1; // 二进制:10,值为2
int bitwiseAnd = a & b; // 二进制:001,值为1
int bitwiseOr = a | b; // 二进制:111,值为7
int bitwiseXor = a ^ b; // 二进制:110,值为6
// 整型向量的位运算
ivec3 v1 = ivec3(5, 7, 9);
ivec3 v2 = ivec3(3, 4, 1);
ivec3 vAnd = v1 & v2; // vAnd = (1, 4, 1)
ivec3 vOr = v1 | v2; // vOr = (7, 7, 9)
ivec3 vXor = v1 ^ v2; // vXor = (6, 3, 8)
ivec3 vNot = ~v1; // 每个分量按位取反
ivec3 vLeftShift = v1 << ivec3(1, 2, 3); // vLeftShift = (10, 28, 72)
六、类型转换
GLSL 的赋值没有自动类型转换或提升功能,对类型的赋值要完全一致。 函数的调用,形参和实参的类型也需要完全一样。
float a = 1; // 是错误的,因为左侧的 a 是浮点型,右侧的 1 是整型
// cannot convert from 'const int' to 'float'
float a = 1.0; // 正确
进行转换类型,只能通过相应类型的构造函数进行。
float f = 1.0;
bool b = bool(f); // 将浮点数转换成布尔类型,将非 0 的数字转为 true,0 转为 false
float f1 = float(b); // 将布尔值转变为浮点数,true 转换为 1.0,false 转换为 0.0
int c = int(f1); // 将浮点数转换成有符号或者无符号整型,直接去掉小数部分
不提供类型转换是因为可以避免某些类型转换带来的性能、复杂性缺陷,简化语言对应的硬件实现。
七、浮点数精度问题
顶点着色器有默认 highp 精度,所以可以不用指定精度,直接使用浮点数即可。
片元着色器没有默认浮点数精度,所以需要指定浮点数精度,否则可能导致编译错误。
1、浮点精度类型
| 精度类型 | 等级 |
|---|---|
| lowp | 低精度,至少 8 位尾数 |
| mediump | 中精度,至少 10 位尾数 |
| highp | 高精度,至少 16 位尾数 |
大多数情况,片元着色器使用中精度 mediump 即可满足。
值得注意的是,浮点数的精度指定不仅仅只是针对标量类型中的 float 类型,而是包括了浮点类型的向量、浮点类型的矩阵和浮点类型相关等类型。
2、使用方式
在顶点着色器和片元着色器中,精度的指定有以下两种
2-1、每个属性独自设置
如果需要不同变量不同的精度,则可以考虑这种方式设置
lowp float color; // color 为 float 型变量,精度为 lowp
in mediump vec2 coord; // coord 为 vec2 型变量,精度为 mediump
highp mat4 m; // m 为 mat4 型变量,精度为 highp
2-2、统一添加
如果一个片元着色器中,希望所有的 float 变量精度是一样的,可以在片元着色器的第一句编写以下代码,则作用整一个片元着色器。
// 格式为 “precision <精度> <类型>”
precision mediump float
这种格式在很多片元着色器中经常看到。
值得注意,如果链接到一起的两个着色器中相同的 uniform 变量,则必须是相同的精度限定符。
2-3、精度不只是为 float 类型设定
精度同样可以作用于 int 、采样器类型,只是没有像 float 类型一样强制显示指定,这是因为浮点数的精度对于图形渲染和计算非常重要。
八、写在最后
EglBox-Android 项目地址:github.com/zincPower/E… (如果对你有所帮助或喜欢的话,赏个 star 吧,码字不易,请多多支持)
如果觉得本篇博文对你有所启发或是解决了困惑,点个赞或关注我呀。
公众号搜索 “江澎涌”,更多优质文章会第一时间分享与你。
