OPENGL ES 2.0 Spec阅读(二)OpenGL ES操作

OpenGL ES基础

  • GL根据一些可选的模式(mode)绘制图元(primitive)。
    • 图元指点、线段,或者三角形
      • 图元由一个或多个顶点(vertex)定义
      • 顶点定义了一个点、边的端点、或者三角形的一个角
      • 诸如位置坐标、颜色、法向量、纹理(texture)坐标等属性是和顶点绑定的
      • 每个顶点被按顺序以同样的方式独立处理(除了裁剪(clipping)操作,裁剪会去除顶点,也会引入新的顶点)
    • 每种mode可独立设置,互不影响。以procedure或者function的形式发送命令来设置mode。
      • 命令会以接收到的顺序执行
      • 数据的绑定直到调用时发生
      • GL命令的解析是基于客户端-服务器模型的。服务器维护了一定数量的上下文,每个都是当前GL状态的封装。客户端可以选择连接到其中一个上下文。
      • GL会与两种帧缓存交互:窗口系统提供的帧缓存和应用创建的帧缓存。窗口系统提供的帧缓存由窗口系统管理,仅在分配一个窗口进行渲染的时候才会初始化GL上下文。

算数计算

对于vertex和fragment shader之外的运算,有以下要求:

  • 精度约为1e-5
  • 浮点值表示的范围至少为\(2^{32}\)
  • 严格满足以下公式
    • \(x \cdot 0 = 0 \cdot x = 0\)
    • \(x \cdot 1 = 1 \cdot x = x\)
    • \(x + 0 = 0 + x = x\)
    • \(x ^ 0 = 1\)
  • 输入非浮点数不应导致GL中断或终止
  • 除0不应导致GL中断或终止

数据转换

  • vertex attribute、颜色和深度表示为整形时,认为其是归一化的
  • 无符号整型转换到浮点型[0,1]:\(f=\frac{c}{2^b-1}\)
  • 无符号整型转换到浮点型[-1,1]:\(f=\frac{2c+1}{2^b-1}\)
  • 浮点到无符号整型转换:\(c=round(clamp_{0,1}(f) \cdot (2^b-1))\)
  • 浮点到有符号整型转换:\(c=round(\frac{clamp_{-1,1}(f) \cdot (2^b-1) - 1}{2})\)
  • 浮点到帧缓存定点型转换:\(c=round(clamp_{0,1}(f) \cdot (2^m-1))\),其中,m不小于帧缓存中各分量的数据宽度,特别的,对于A,m还应不小于2

GL状态

  • GL状态分为两种,GL Server状态和GL Client状态,绝大部分的GL状态都是Server状态。
  • 每个GL上下文实例表示了一套完整的GL Server状态;每次从Client到Server的连接表示了一套GL Client状态和GL Server状态。
  • 一组上下文可以共享特定的状态。

GL命令语法

许多GL执行的是相同的操作,仅仅是提供参数的方式有区别。为了更清晰地表达,采用了类型+参数格式的方法来命名函数。参数格式不超过4个字符,第一个表示了必须提供的给定类型数值得个数,第二个或者第二、三个表明了给定参数的类型,最后一个,如果有的话,会是v,表明了参数是通过传指针的方式而非一个个通过参数输入的方式传入。即,命令格式如下:

\[rtype\ \bold{Name}\{ \epsilon 1234\}\{ \epsilon i f\}\{ \epsilon v\} ([\it{args},]\ T\ arg1, ..., T\ argN\ [, \it{args}]); \]

GL数据类型和C数据类型不同,例如,GL int型的类型名为GLint,不一定和C的int类型实现相同。GL支持的数据类型如下:

GL Type Minimum Bit Width Description
boolean 1 布尔型
byte 8 有符号二进制整型
ubyte 8 无符号二进制整型
char 8 字符组成的字符串
short 16 有符号2的补码二进制整型
ushort 16 无符号二进制整型
int 32 有符号2的补码二进制整型
uint 32 无符号二进制整型
fixed 32 有符号2的补码16.16缩放整型
sizei 32 非负二进制整型大小
enum 32 枚举二进制整型
intptr ptrbits 有符号2的补码二进制整型
sizeiptr ptrbits 非负二进制整型大小
bitfield 32 比特字段
float 32 浮点值
clampf 32 钳位到[0,1]的浮点值

基本GL操作

注意,下图仅为描述GL的工具,并非GL实现时的严格要求。
GL框图

GL错误

以下函数用来获取GL的错误码。仅会记录发生的第一个错误的错误码。

enum GetError(void);

当错误码被函数获取后,错误标志和错误码清零,重新开始记录。返回NO_ERROR代表没有错误。

Error Description Offending command ignored?
INVALID_ENUM enum参数超出范围
INVALID_FRAMEBUFFER_OPERATION 帧缓存不完整
INVALID_VALUE 数值参数超出范围
INVALID_OPERATION 当前状态下操作违法
OUT_OF_MEMORY 没有足够的内存来执行操作
除OUT_OF_MEMORY错误外,其余的错误全部忽略,不会影响GL状态或者帧缓存内容。

图元和顶点

  • 有七种几何对象是用顶点矩阵的方式定义的:点、线段条带、线段环、独立线段、三角形条带、三角扇以及独立三角。
  • 每个顶点由多个通用顶点属性给定。
  • 每种属性可以由1-4个标量值给定。
    顶点处理流程

图元类型

顶点通过命令DrawArraysDrawElements(我们将它们统称为DrawCall)传递给GL。除顶点矩阵的大小限制外,不限制顶点的数量。这些函数中mode这个参数定义了所画图元的类型,类型和对应的mode如下:

  • 点:POINTS。每个顶点定义一个点。
  • 线段条带:LINE_STRIP。需要至少两个顶点。这种模式下,第一个顶点定义了第一条线段的开始点,第二个顶点定义了第一条线段的结束点和第二条线段的开始点,以此类推。
  • 线段环:LINE_LOOP。除最后一个顶点和第一个顶点间有一个线段外,和线段模式相同。
  • 独立线段:LINES。每两个顶点表示一个线段。
  • 三角形条带:TRIANGLE_STRIP。从第三个顶点开始,每个顶点和之前的两个顶点组成一个三角形。
  • 三角扇:TRIANGLE_FAN。每个三角形都以第一个顶点作为起始顶点,之后,从第三个顶点开始,每个顶点和前一个顶点作为三角形的另外两个顶点。
  • 独立三角形:TRIANGLES。每三个顶点定义一个三角形。

当前顶点状态

顶点的属性最多可以有MAX_VERTEX_ATTRIBS个,第i个属性用第i-1个slot表述。当没用使用顶点数组来定义顶点属性时,可以使用以下命令来修改当前顶点状态:

void VertexAttrib{1234}{f}  (uint index, T values);
void VertexAttrib{1234}{f}v (uint index, T values);

其中,index表示修改第index个slot属性,数字则代表将该的前几个分量设置为给定值,而将后面的分量设置为默认值。对于属性(x,y,z,w),对应的默认值为(0,0,0,1)。

顶点数组

顶点属性数据可以存储在客户或者服务器端,当在客户端时,称为顶点数组;在服务器端时,称为缓存对象。
以下命令用于编辑顶点数组:

void VertexAttribPointer(uint index, int size, enum type, boolean normalized, sizei stride, const  void *pointer);

其中,index指定了要修改属性的slot;size指定了输入数组中分量个个数,这几个分量组成一个基本单元,称为element;type定义了数组中数据的类型,可以是BYTE, UNSIGNED_BYTE, SHORT, UNSIGNED_SHORT, FIXED, and FLOAT;normalized表示了数据是否需要按照GL约定进行数据转换;stride给定了element的地址和下一个element的地址间隔的字节数;pointer给定了第一个element的第一个数值的地址(按字节计数)。注意,这里仅给定了起始地址指针和数据加载方法,并没有给定图元的数量。
每个通用定点属性数组可以使用以下函数独立使能和关闭

void EnableVertexAttribArray(uint index);
void DisableVertexAttribArray(uint index);

在DrawCall命令中,在数组单元传输到GL时,每个通用属性都被扩展到4维
命令

void DrawArrays(enum mode, int first, sizei count);

通过顺序传输firstfirst+count-1element来定义一系列集合图元。当vertex shader需要没有被使能的通用属性时,从当前默认顶点属性中获取(由VertexAttrib函数给定)。
命令

void DrawElements(enum mode, sizei count, enum type, void *indices);

通过顺序countelement的索引(存储在indeices中)来定义一系列集合图元,即,第ielement的通用属性为indices[i]。当vertex shader需要没有被使能的通用属性时,从当前默认顶点属性中获取(由VertexAttrib函数给定)。typeindices数组的类型,只能是UNSIGNED_BYTE或者UNSIGNED_SHORT。当vertex shader需要没有被使能的通用属性时,从当前默认顶点属性中获取(由VertexAttrib函数给定)。
假设MAX_VERTEX_ATTRIBS=n,则client state需要n个布尔值、n个指针、n个整数stride值、n表示数组类型的符号常量、n个表示每个element数值个数的整数值,以及n个表示正则化的布尔值。所有布尔值的初始值都为false,指针都为NULL,stride都是0,数组类型FLOAT,数值个数为4。

缓存对象

GL缓存对象提供了一种分配、初始化server memory和从server memory渲染的机制,以便于常用的client数据存储在server meory中。buffer object的命名空间是无符号整数,并且0保留给GL使用。

通过以下命令绑定一个未使用的buffer object名到ARRAY_BUFFER来创建一个buffer object(target=ARRAY_BUFFER):

void BindBuffer(enum target, uint buffer);

该命令将会生成一个新的状态向量初始化为一个大小为0的内存缓存,其内容下表:

名称 类型 初始值 合法值
BUFFER_SIZE interger 0 任何非负整数
BUFFER_USAGE enum STATIC_DRAW STATIC_DRAW, DYNAMIC_DRAW, STREAM_DRAW
BindBuffer也可用于绑定一个已经存在的buffer object,绑定成功时状态不会由任何改变,且之前的任何绑定都会断开。
当buffer object被绑定后,对targe的GL操作就会影响绑定的buffer object,而对target的查询会返回绑定的buffer object的状态。初始状态下ARRAY_BUFFER绑定的是保留名称0,而0没有对应的buffer object,因此对ARRAY_BUFFER的修改和状态查询会导致GL错误。

buffer object通过以下命令删除:

void DeleteBuffers(sizei n, const uint *buffers);

其中,buffers包含要删除的n个buffer object名,如果buffers中给定了未使用的名称或者0,会被直接忽略。

通过以下函数获取n个未使用的名称

void GenBuffers(sizei n, uint *buffers);

获取到的名称被标记为已使用,注意这里所谓的已使用仅仅针对于GenBuffer来说,在未绑定之前,它仍无法获取到有效的缓存状态。

通过以下函数创建和初始化存储在buffer object中的数据:

void BufferData(enum target, sizeiptr size, const void *data, enum usage);

target设置为ARRAY_BUFFER,size表示数据的大小,单位为基础机器单位(通常为字节),data指向client端内存中的数据,usage为枚举值,作为性能优化指引使用,但不约束数据的实际存储方式,有效值如下:

  • STATIC_DRAW:数据被应用给定一次,且被GL drawcall使用许多次
  • DYNAMIC_DRAW:数据被应用重复给出,且被GL drawcall使用许多次
  • STREAM_DRAW:数据被应用给定一次,且最多仅被GL drawcall使用少数几次
    客户端必须按照客户端平台的要求对齐数据。

可以用以下命令修改buffer中保存的部分或全部数据:

void BufferSubData(enum target, intptr offset, sizeiptr size, const void *data);

target设置为ARRAY_BUFFER,offset表示修改的偏移量,size表示数据的大小,单位为基础机器单位(通常为byte或word),data指向client端内存中的数据。

buffer object中的vertex array

当ARRAY_BUFFER的绑定非0时,VertexAttribPointer和drawcall的行为会有所不同。
VertexAttribPointer复制ARRAY_BUFFER_BINDING(当前ARRAY_BUFFER绑定的buffer名)到index对应的VERTEX_ATTRIB_ARRAY_BUFFER_BINDING中。
DrawArraysDrawElements的通用属性采用存储在array buffer中的数据,pointer为相对于buffer object的偏移值。

GL中数据存储在client端和server端可以任意混合使用,不做限制。

buffer object中的array indices

可以使用BindBuffer将buffer object名绑定到ELEMENT_ARRAY_BUFFER来使用buffer object中的array indices。

顶点着色器

shader源代码需要加载到shader object中,然后被编译;或者采用预编译的shader二进制码直接加载到shader object中。如果SHADER_COMPILER=true,则支持编译;如果NUM_SHADER_BINARY_FORMATS大于0,则支持二进制码直接加载。

之后, shader object被连接到一个program object。这个program object接下来被link并从所有被连接到该program object的shader object产生可执行的二进制码。当link好的program object被设置为当前program object时,就会使用其中的可执行代码。

加载和编译shader源代码

shader object的命名空间是无符号整数,0保留给GL使用。shader object的命名空间是和program object共享的。
通过以下命令来创建一个空的shader object:

uint CreateShader(enum type);

type指定了程序类型,VERTEX_SHADER或者FRAGMENT_SHADER。

以下命令用于将源码加载到shader object中:

void ShaderSource(uint shader, sizei count, const char **string, const int *length);

shader为shader object名,stirng包含了count个带有可选null结尾的字符串,length是一个指定这些字符串长度的数组,单位为char,如果length数组中某个值为负,则忽略length,代表字符串是以null结尾的,如果length为NULL指针,则所有的字符串都是null结尾的。

shader object加载源码后,用以下命令进行编译:

void CompileShader(uint shader);

可以使用GetShaderiv命令获取编译成功与否,用GetShaderInfoLog命令获取编译过程输出的信息。

以下命令可以用于释放shader编译器分配的资源:

void ReleaseShaderCompiler(void);

shader compiler支持的表示范围和精度可以用GetShaderPrecisionFormat查询。

shader object可以使用以下命令删除:

void DeleteShader(uint shader);

如果shader没有连接到任何program object,它会立即被删除,否则其DELETE_STATUS状态会被标记为真,直到从所有program断开后被删除。

通过以下命令来加载二进制码:

void ShaderBinary(sizei count, const uint *shaders, enum binaryformat, const void *binary, sizei length);

shaders包含了count个shader object,binary指向clinet memory中length字节长的预编译二进制码,binaryformat表示了预编译码的格式。

program object

GL执行的程序称为executable,定义executable需要的所有信息被封装在一个program object中,使用以下命令创建一个空的program object:

uint CreateProgram(void);

使用以下命令将shader object连接到一个program object:

void AttachShader(uint program, uint shader);

shader object在加载程序源码或者编译之前就可以连接到program object。同一类型的shader object不能连接到同一个program object,但同一个shader object可以连接到不同的program object。

以下命令用于断开shader object和program object的连接:

void DeattachShader(uint program, uint shader);

用以下命令进行link:

void LinkProgram(uint program);

可以用GetProgramiv命令获取link成功与否,用GetProgramInfoLog命令获取lin操作的详细信息。

可以用以下命令将合法的executable变为当前渲染状态的一部分:

void UseProgram(uint program);

当合法的program object被使用时,修改连接的shader object、编译连接的shader object、连接新的shader object、断开已连接的shader object、进行link但是失败都不会影响可执行代码,但是如果重新进行link且成功,新连接的代码将会被用作当前渲染状态的一部分。

可以使用以下命令删除program object:

void DeleteProgram(uint program);

当program ojbect没有被GL用作当前程序时,它直接被删除,否则它会标记删除并等待不被任何上下文使用时删除。

shader的变量

shader中使用的变量分为几种:vertex attribute是每个顶点特有的数值;uniform是每个程序特有的程序执行时不变的常量;sampler时特殊的用于纹理访问的uniform;varying variable是vertex shader执行的结果,会在后续流水线中使用。

vertex attribute

vertex shader可以定义有名属性变量,并绑定到通用顶点属性。绑定可在link前由应用指定,或者在link时由GL自动完成。对于类型为float、vec2、vec3、vec4的有名属性变量,会分别取所绑定的通用属性i的x、(x,y)、(x,y,z)、(x,y,z,w)。而对于类型为mat2、mat3、mat4的有名属性变量,会分别取第i个及第i+1、i+2、i+3个通用顶点属性的(x,y)、(x,y,z)、(x,y,z,w)。

只有shader运行时使用的atrribute才会被认为是active的,否则即使声明了也不会认为是active的,而对于compiler和linker不能确定的情况,attribute会是active的。用以下命令来获取active的vertex attribute的信息:

void GetActiveAttrib(uint program, uint index, sizei bufSize, sizei *length, int *size, enum *type, char *name);

index表示在active的vertex attribute中的索引顺序,有效范围为从0到ACTIVE_ATTRIBUTES-1,ACTIVE_ATTRIBUTES可以用GetProgramiv查询到。name返回vertex attribute的名称,是null结尾的字符串。name的最大长度由bufSize给定,而name的实际长度由length返回(不包括null结尾)。最长的属性名长度为ACTIVE_ATTRIBUTE_MAX_LENGTH,可以用GetProgramiv查询。type返回vertex attribute的类型,可以是FLOAT、FLOAT_VEC2、FLOAT_VEC3、FLOAT_VEC4、FLOAT_MAT2、FLOAT_MAT3、FLOAT_MAT4。size给定了vertex attribute包含相应数据类型数据的数量。

可以用以下命令查询相应名称vertex attribute对应的generic attribute的索引值:

int GetAttribLocation(uint program, const char *name);

也可以使用以下命令显式地绑定vertex attribute和generic attribute:

void BindAttribLocation(uint program, uint index, const char *name);

当程序link时,任何没有进行显式bind的vertex attribute会自动bind,bind的结果可以通过GetAttribLocation命令查询。

应用可以将多个vertex attribute名绑定到同一位置,这被称为aliasing,但aliasing仅在可执行程序中只有一个名字是active的,或者shader中没有一条路径同时使用了多于一个绑定的同一位置的名字的情况下才被允许。

uniform变量

uniform是每个program object指定的状态,在多个图元中都保持为常量。uniform仅在compiler和linker认为其在可执行程序中实际使用时才被视作active。最大的uniform的数量由MAX_VERTEX_UNIFORM_VECTORS指定。当程序成功link后,会自动将所有active uniform初始化为0,并为其生成location。可以使用Uniform系列函数按照location来修改uniform的值。

可以用以下命令获取uniform的location:

int GetUniformLocation(uint program, const char *name);

需要注意,name不能是结构体或者结构体数组或者向量和矩阵的分量,可以使用“."和“[]”来指定结构体的成员和数组的单元。

可以通过以下命令确定active的uniform的信息:

void GetActiveUniform(uint program, uint index, sizei bufSize, sizei *length, int *size, enum *type, char *name);

其参数和GetActiveAttrib类似,index有效范围在0到ACTIVE_UNIFORMS-1,ACTIVE_UNIFORMS可以使用GetProgramiv查询。program object中最长的uniform名由ACTIVE_UNIFORM_MAX_LENGTH确定,可使用GetProgramiv查询。type可以是FLOAT、FLOAT_VEC2、FLOAT_VEC3、FLOAT_VEC4、INT、INT_VEC2、INT_VEC3、INT_VEC4、BOOL、BOOL_VEC2、BOOL_VEC3、BOOL_VEC4、FLOAT、FLOAT_MAT2、FLOAT_MAT3、FLOAT_MAT4、SAMPLER_2D、SAMPLER_CUBE。

以下命令用于向uniform装载数据:

void Uniform{1234}{if}(int location, T value);
void Uniform{1234}{if}v(int location, sizei count, T value);
void UniformMatrix{234}fv(int location, sizei count, boolean transpose, const float *value);

仅有Uniform1i{v}命令可以用于装载sampler的值。矩阵应该以列主序模式存储,transpose必须为FALSE。boolean型uniform可以用Uniform{1234}i{v}命令装载值。除上所述外,Uniform系列命令的数据必须与uniform的类型和大小完全相同。

sampler

sampler是标识纹理采样所用的texture object的特殊uniform。使用BindTexture来绑定texture object。使用ActiveTexture命令来选择绑定的texture object。不允许多个sampler指向同一个texture。仅当sampler司机在程序执行时使用时,才会是active的。

varying变量

varying变量是当渲染时要在图元范围内进行差值的变量。最大的差值器的数量由MAX_VARYING_VECTORS给出。gl_Position不是varying变量,不计入范围内。

shader执行

纹理访问

vertex shader可用的texture的最大数量为MAX_VERTEX_TEXTURE_IMAGE_UNITS,最大值为0说明不支持vertex shader进行纹理访问。fragement shader可用的texture的最大数量为MAX_TEXTURE_IMAGE_UNITS。vertex shader和fragment shader访问的texture数量合起来不超过MAX_COMBINED_TEXTURE_IMAGE_UNITS,且即使两者访问的是同一个texture,也要计算两次。

纹理访问的放大缩小后面将会详细描述。在vertex shader中,使用视窗坐标系下的纹理坐标的偏导自动计算lod是不可能的,因此不会自动选择lod,而是采样可选的lod输入参数。

合法性验证

并不是总能在link阶段确定program object是否确实能够执行,因此,在第一次渲染命令发送时,会对程序进行合法性验证,如果不通过,会产生INVALID_OPERATION错误。这种错误会在两个具有不同类型的active的sampler指向同一texture时产生。但是,仅返回错误并没有提供程序执行不正确的足够信息,因此建议用以下命令来对program object进行合法性验证:

void ValidateProgram(uint program);

该命令会改变program object的布尔状态VALIDATE_STATUS,它可以用GetProgramiv查询。

实现必须保证shader object的compile和program object的link不会因为缺少指令空间和缺少临时变量而失败。

未定义行为

程序执行时可能用run time计算得的量作为数组或者矩阵的索引,则可能会导致未定义的行为,甚至导致程序终止。

需要的状态

--

图元装配和着色后顶点处理

图元的类型由drawcall的mode参数给定。在顶点处理之后,会据此依次进行裁剪坐标系下的透视除法、视窗映射(包括深度范围缩放)、图元裁剪、裁剪varying

视窗变换

vertex shader执行会产生顶点在裁剪坐标系下的坐标 gl_position。之后,通过裁剪坐标系下的透视除法将坐标转换为正规化设备坐标(normalized device coordinate),然后进行视窗变换将坐标转换为窗口坐标。
对于齐次坐标\(\begin{pmatrix} x_c \\ y_c \\ z_c \\ w_c \end{pmatrix}\),其normalized device coordinate为\(\begin{pmatrix} x_d \\ y_d \\ z_d \end{pmatrix} = \begin{pmatrix} \frac{x_c}{w_c} \\ \frac{y_c}{w_c} \\ \frac{z_c}{w_c} \end{pmatrix}\)

控制视窗

视窗变换由视窗的宽\(p_x\)和高\(p_y\),和它的中心\((o_x, o_y)\)(都以像素为单位)决定。视窗变换有:

\[\begin{pmatrix} x_w \\ y_w \\ z_w \end{pmatrix} = \begin{pmatrix} \frac{p_x}{2} x_d+o_x \\ \frac{p_y}{2}y_d+o_y \\ \frac{f-n}{2}z_d+\frac{n+f}{2} \end{pmatrix} \]

其中,近平面n和远平面f由以下命令给定:

void DepthRangef(clampf n, clampf f);

n和f被限制在[0,1]。==\(z_w\)被表示为定点,位数和depth buffer相同。
视窗变换参数由以下命令给定:

void Viewport(int x, int y, sizei w, sizei h);

x和y是视窗的左下角的坐标,w和h是视窗的宽高。因此,

\[o_x=x+\frac{w}{2} \\ o_y=y+\frac{h}{2} \\ p_x=w \\ p_y=h \]

视窗的宽高被限制到依赖于实现的的最大值,最大值可以用Get命令获取到。

图元裁剪

图元被以裁剪体为限裁剪。在裁剪坐标系下,裁剪体为

\[-w_c \le x_c \le w_c \\ -w_c \le y_c \le w_c \\ -w_c \le z_c \le w_c \]

如果图元为点,它在near和far平面之外时,clipping会丢弃它,否则不变;如果图元为线段,它完全在near和far平面之外时,clipping会丢弃它,完全在near和far平面之中时保留它,当有部分在near和far平面之间时,会生成新的顶点,并计算差值系数t;如果图元为三角形,当它每条边都在裁剪体之内时,不变,否则会裁剪或者丢弃,在裁剪时会生成新的顶点和新的三角形。如果恰好三角形和裁剪体的边界相交,那么裁剪的三角形必须包括边界上的点。 如果线段的顶点的\(w_c\)的符号不同,也会裁剪出多个图元,但是GL实现仅要求处理\(w_c>0\)的图元

裁剪varying

假设点\(P_1\)和点\(P_2\)对应的属性值为\(c_1\)\(c_2\),那裁剪点\(P\)的属性值\(c\)\(c=tc_1+(1-t)c_2\)

posted @ 2022-05-25 19:44  xvsay  阅读(60)  评论(0)    收藏  举报