【UnityShader入门精要学习笔记】第四章(6)法线变换、内置变量以及本章答疑

发布时间:2024年01月16日

在这里插入图片描述
本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:

  • 书本中句子照抄 + 个人批注
  • 项目源码
  • 一堆新手会犯的错误
  • 潜在的太监断更,有始无终

总之适用于同样开始学习Shader的同学们进行有取舍的参考。



(该系列笔记中大多数都会复习前文的知识,特别是前文知识非常重要的时候,这是为了巩固记忆,诸位可以直接通过目录跳转)

复习

知识点复习

坐标空间的转换

上节课中,我们介绍了从顶点到屏幕的渲染流水线的几何阶段中发生的坐标空间变换。从模型空间到世界空间再到齐次裁剪空间,然后转换到NDC,再转换到观察空间,最后转为屏幕空间。我们之所以使用如此之多的空间变换,是由于某些概念和描述只有在其特定空间之下才有意义。例如Unity场景中的坐标描述只能相对于世界空间,而片元裁剪则是只能在齐次裁剪空间进行等等。

一个坐标空间中必须包含的就是其原点位置和坐标系。但是不同空间的原点位置也是不一样的,因此想要描述从一个空间转换到另一个空间,常常需要以其中一个空间为坐标参考。因此,坐标空间之间也就存在了层级关系,我们将作为参考系的坐标空间称为父空间 ,以父空间 为参考的称为子空间

因此,每个坐标空间都可以描述为另一个坐标空间(父空间)的子空间。对坐标空间的变换实质上就是在父空间和子空间之间对点和向量进行变换 。由于不同空间的点和向量在坐标上的描述一定是唯一对应的,我们也将其称之为空间映射关系

那么空间到空间的这种变换函数关系,我们可以用上节学习的四阶矩阵来进行描述。设 父空间为 P ,子空间为 C 父空间为P,子空间为C 父空间为P,子空间为C,那么 P → C P \to C PC的映射矩阵就是 T P → C T_{P\to C} TPC?,反之 C → P C \to P CP的映射矩阵就是 T C → P T_{C \to P} TCP?

A P = T P → C A C A_P = T_{P\to C}A_C AP?=TPC?AC?
B C = T C → P B P B_C = T_{C \to P}B_P BC?=TCP?BP?

空间映射关系其实就是应用了一次齐次变换矩阵。


顶点的坐标空间变换

顶点着色器中,一个顶点究竟是如何变换到最终的屏幕空间的:它要经历以下几个空间变换

在这里插入图片描述
上述空间转换过程被我们称为一个MVP变换,M代表model,V代表view,P代表projection投影。

顶点着色器最基本的任务就是将顶点从模型空间转换到裁剪空间,也就是需要在顶点着色器中实现MVP变换。


模型空间

模型空间(model space) 代表了模型自身所处的坐标空间,或者我们也可以称其为对象空间(object space)或者局部空间(local space) ,每个模型都有属于自己的独立的模型空间,当模型发生变换时,其模型空间也随之发生变换。
在这里插入图片描述
模型中的顶点坐标是四维的,需要被拓展到齐次坐标系下。不过由于我们在Unity中描述某点都是以世界空间为基准,因此模型空间我们不大关心。

世界空间

世界空间(world space) 用于描述模型的绝对位置,即在Unity场景中的世界坐标系位置,世界空间的原点就是游戏场景的中心。

在Unity中,物体有Transfrom组件的Position属性来表示它的坐标。这个Position表示的是物体相对于父物体(parent)为原点的坐标,如果一个GameObject没有任何父节点,则Transfrom代表了物体在世界空间中的坐标。Transfrom的Rotation和Scale也同理。

顶点变换的第一步就是从模型空间变换到世界空间

对于空间的变换,我们的应用是先缩放,在旋转,再平移,因此,模型空间到世界空间的变换矩阵为:

M m o d e l → w o r l d = M t r a n s M r o t a t e M s c a l e P w o r l d = M m o d e l → w o r l d P m o d e l M_{model \to world} = M_{trans}M_{rotate}M_{scale} \newline P_{world}=M_{model \to world} P_{model} Mmodelworld?=Mtrans?Mrotate?Mscale?Pworld?=Mmodelworld?Pmodel?

模型空间转换到世界空间可以视为MVP转换中由 M → V M \to V MV的准备工作,因为我们要以世界空间为父空间描述其他空间的坐标。

观察空间

世界空间到观察空间代表了 M → V M \to V MV的流程,

观察空间(view space)或者称视图空间是摄像头视角下的空间坐标,也称为摄像机空间(camera space)

观察空间决定了摄像机渲染游戏使用的视角。摄像机的位置代表了原点,而我们在之前讲过,观察空间使用的是右手坐标系,也就是屏幕到人眼的方向为正坐标。

现在我们想要将世界空间的坐标变换到观察空间中,这个变换称为观察变换(view transfrom)

想要实现世界空间到观察空间的变换,只需获得场景下摄像机的Tramsform信息,也就是世界空间坐标信息,我们知道这个坐标是观察空间乘以变换矩阵得到的世界空间。因此找到世界空间到观察空间的逆矩阵,就可以将模型坐标从世界空间变换到观察空间。
在这里插入图片描述

以上图为例,不难发现观察空间到世界空间的逆变换就是缩放1/1倍,绕x轴旋转-30°,最后平移(0,-10,10)。

并且注意,世界空间转换到观察空间,左手坐标系变为右手坐标系,则z轴方向翻转,所以z轴坐标取反:
M w o r l d → v i e w = M n e g a t e ? z M v i e w → w o r l d ? 1 P v i e w = M w o r l d → v i e w P w o r l d M_{world \to view} =M_{negate\space z}M_{view \to world}^{-1} \newline P_{view}=M_{world \to view} P_{world} Mworldview?=Mnegate?z?Mviewworld?1?Pview?=Mworldview?Pworld?

裁剪空间

接下来我们要将观察空间转换到裁剪空间(clip space ,也被称为齐次裁剪空间)。这个用于变换的矩阵叫做裁剪矩阵clip matrix,也叫投影矩阵(projection matrix)

裁剪空间的目的是确定那些图元需要被渲染,图元如果完全在裁剪空间内则保留;如果部分在裁剪空间内,就根据边缘对图元裁剪,保留空间内的部分进行渲染;如果图元完全不在空间内则被舍弃。而裁剪空间的大小则是由视锥体(view frustum) 决定。

视锥体指的是空间中的一块区域,这块区域决定了摄像机可以看到的空间。视锥体决定了摄像机能看到的视野范围,视锥体是一个六面体,它的每个面被称为裁剪平面clip planes

在这里插入图片描述

摄像机有两者投影模式,一种是透视投影(perspective projection) ,另一种是正交投影(orthographic projection)

透视投影模拟了正常人眼视角下的随距离产生的透视效果,而正交投影则是不带透视效果的渲染。

在视锥体的所有裁剪平面中,有两个裁剪平面比较特殊,分别被称为近裁剪平面(near clip plane)远裁剪平面(far clip plane) 。他们决定了摄像机可以看到的深度范围。

由于透视投影的视锥是一个金字塔形,因此如果通过点在空间上的位置判断是否在视锥内从而实现裁剪是相当麻烦的。因此,我们想用一种更通用的方式来进行裁剪,这种方式就是通过一个投影矩阵把顶点转换到一个裁剪空间中。

投影矩阵的目的有两个:

  • 为投影做准备,虽然投影矩阵名称中包含了投影,但没有真正进行投影的工作,真正的投影发生在齐次除法过程中
  • 其次是对xyz分量进行缩放,通过投影矩阵可以将w分量作为一个范围值,若xyz分量都位于这个范围中则说明该顶点位于裁剪空间内。

在之前讲齐次坐标的时候,我们使用了w分量,不过通常w分量是固定值,点的w分量为1,而方向向量的w分量为0,经过投影矩阵变换后w分量可以被我们用于判断顶点是否在裁剪空间内。


投影矩阵

想要将顶点从观察空间转换到裁剪空间,则需要乘以投影矩阵,最后用齐次除法除以裁剪分量。

在这里插入图片描述

投影矩阵的原理其实很简单, 本质上来讲就是将视锥转换为NDC,就是计算出从正交立方体到NDC的这个矩阵(透视视锥的原理也相同,需要先将透视视锥的frustum映射为正交立方体再使用上述步骤)

则有:
M p e r s p = M o r t h o M p e r s p → o r t h o 正交投影: P c l i p = M o r t h o P v i e w 透视投影: P c l i p = M p e r s p P v i e w M_{persp} = M_{ortho} M_{persp \to ortho}\newline 正交投影: P_{clip}=M_{ortho} P_{view}\newline 透视投影:P_{clip}=M_{persp} P_{view} Mpersp?=Mortho?Mpersportho?正交投影:Pclip?=Mortho?Pview?透视投影:Pclip?=Mpersp?Pview?

这只是将顶点从观察空间变换到四维的齐次裁剪空间,在齐次裁剪空间中进行裁剪工作:
如果一个顶点在视锥内,那么满足:

? w < = x < = w ? w < = y < = w ? w < = z < = w -w<=x<=w \newline -w<=y<=w \newline -w<=z<=w ?w<=x<=w?w<=y<=w?w<=z<=w
不满足以上条件的顶点将被舍弃以完成对片元的裁剪工作。

屏幕空间

我们现在知道了那些顶点保留,那些舍弃。这样我们就可以投影到屏幕空间了。

屏幕空间是二维空间,在此之前我们还需要将顶点从四维的齐次裁剪空间投射到三维的NDC空间再投射到屏幕空间。

于是从齐次裁剪空间到屏幕空间过程分为两步:

  • 进行标准齐次除法(homogeneous division),也称为透视除法(perspective division)。这一步很简单,其实就是对齐次坐标系的xyzw分量分别除以w分量,使得w分量变为1即可将其投射回三维空间,这个新得到的坐标空间被我们称为归一化的设备坐标NDC
  • 现在从齐次裁剪空间转换到了NDC,我们会得到分量在[-1,1]的单位正方体。然后我们在渲染管线的几何化阶段使用这个NDC的坐标来计算屏幕空间上的坐标即可,只需简单缩放就可以完成。

从齐次裁剪空间投射到NDC空间(这一步代表了MVP变换中从观察视图转换到投影视图的过程) :

( x ′ , y ′ , z ′ ) = ( x w , y w , z w ) (x',y',z')=(\frac{x}{w},\frac{y}{w},\frac{z}{w}) (x,y,z)=(wx?,wy?,wz?)

NDC再做视口映射到屏幕坐标:
s c r e e n x = x ′ ? p i x e l W i d t h 2 + p i x e l W i d t h 2 s c r e e n y = y ′ ? p i x e l H e i g h t 2 + p i x e l H e i g h t 2 screen_x=\frac{x'·pixelWidth}{2}+\frac{pixelWidth}{2}\newline screen_y=\frac{y'·pixelHeight}{2}+\frac{pixelHeight}{2} screenx?=2x?pixelWidth?+2pixelWidth?screeny?=2y?pixelHeight?+2pixelHeight?

由于Unity中,从裁剪空间到屏幕空间的转换是底层完成的,因此我们的顶点着色器只需要将顶点转换到齐次裁剪空间即可。


法线变换

法线变换是一种特殊的变换。**法线(normal)**也被称为法向量,犹原记得初中物理中学习光的反射中的关于法线的概念。法线是一种需要特殊处理的向量,在游戏中,模型的一个顶点往往会携带许多额外的信息,而顶点法线就是其中一种。当我们变换一个模型的时候,不仅需要变换它的顶点,还需要变换顶点法线,以便于在后续处理中(例如片元着色器)计算光照信息。

之前我们将向量变换的时候,总能用一个3x3或者4x4的矩阵实现从坐标空间A到坐标空间B的变换,然而在对法线进行变换的时候,如果使用同一个变换矩阵,可能就无法保持法线的垂直性。

在这里插入图片描述
在初中学习光的反射的时候,会用到这样的图片。垂直于反射平面的那条线就是法线,然而在模型中,法线是每一个顶点都拥有的属性。

在这里插入图片描述

关于切线空间计算的文章

在模型中,我们的切线空间一般而言是根据模型的UV方向来决定的,UV方向确定xy轴,一般U方向代表切线T,V方向代表副切线B,法线N就是正交的z轴方向(沿着面片的正面为正方向)。

当然,由于法线是顶点的信息之一,法线是可以自定义方向的,这也会导致切线方向随之改变。

由于切线与UV坐标相关,因此我们对其可以直接应用3阶线性变换 M A → B M_{A\to B} MAB?,将切线从原坐标空间A变换到新坐标空间B:

T B = M A → B T A T_B=M_{A\to B}T_A TB?=MAB?TA?

在这里插入图片描述
其实我认为,由于法线方向可以自定义,严格来说不垂直于平面的法线不算错误结果,或许就是有这种需求呢?法线只需要满足垂直于切线平面即可。

法线与切线垂直关系可以表示为内积为0,即: T A ? N A = 0 T_A·N_A=0 TA??NA?=0,给定变换矩阵 M A ? B M_{A-B} MA?B?,已知 T B = M A ? B T A T_B=M_{A-B}T_A TB?=MA?B?TA?。现在想要一个变换矩阵 G G G来变换法线 N A N_A NA?,使得变换后的法线依然保持与切线垂直,即内积为0:

T B ? N B = ( M A ? B T A ) ? ( G N A ) = 0 ( M A ? B T A ) ? ( G N A ) = ( M A ? B T A ) T ( G N A ) = T A T ( M A → B T G ) N A = 0 T_B·N_B =(M_{A-B}T_{A})·(GN_A)=0\newline (M_{A-B}T_{A})·(GN_A)=(M_{A-B}T_{A})^T(GN_A)=T^T_A(M^T_{A\to B}G)N_A=0 TB??NB?=(MA?B?TA?)?(GNA?)=0(MA?B?TA?)?(GNA?)=(MA?B?TA?)T(GNA?)=TAT?(MABT?G)NA?=0

由于 T A ? N A = 0 T_A·N_A=0 TA??NA?=0,因此如果 M A → B T G = E M^T_{A\to B}G=E MABT?G=E上式成立

所以 G = ( M A → B T ) ? 1 = ( M A → B ? 1 ) T G=(M^T_{A\to B})^{-1}=(M^{-1}_{A\to B})^T G=(MABT?)?1=(MAB?1?)T

因此只需求出 M A → B M_{A\to B} MAB?的逆矩阵的转置即可对法线进行正确的线性变换。且若 M A → B M_{A\to B} MAB?为正交矩阵,则逆矩阵的转置就是它本身。

如果基础变换的缩放系数是统一的,那么可以直接除以统一缩放系数k, ( M A → B T ) ? 1 = 1 k M A → B (M^{T}_{A\to B})^{-1}=\frac{1}{k}M_{A\to B} (MABT?)?1=k1?MAB?

(吐槽一下,书中 ( M A → B T ) ? 1 (M^{T}_{A\to B})^{-1} (MABT?)?1非得叫做 M A → B M_{A\to B} MAB?的逆转置矩阵吗?不觉得直接叫转置矩阵的逆矩阵好一点吗?逆转置矩阵不就搞不清楚是转置矩阵的逆矩阵还是逆矩阵的转置矩阵了吗?)


Unity Shader的内置变量(数学篇)

变换矩阵变量

Unity中给出了所有内置的可用于坐标空间变换的矩阵:

(变量名取得好,编程没烦恼)
变量名描述
UNITY_MATRIX_MVP当前模型视图投影矩阵,通常用于把顶点/方向矢量从模型空间转换到裁剪空间(从名字可以看出是MVP复合矩阵变换)
UNITY_MATRIX_MV当前模型视图矩阵,通常用于把顶点/方向矢量从模型空间转换到视角(观察)空间
UNITY_MATRIX_V当前视图矩阵,通常用于把顶点/方向矢量从世界空间转换到视角(观察)空间
UNITY_MATRIX_P当前的投影矩阵,通常用于把顶点/方向矢量从视角(观察)空间转换到裁剪空间
UNITY_MATRIX_VP当前视图投影矩阵,通常用于把顶点/方向矢量从世界空间转换到裁剪空间
UNITY_MATRIX_T_MVUNITY_MATRIX_MV矩阵的转置
UNITY_MATRIX_IT_MV模型视图矩阵UNITY_MATRIX_MV的转置矩阵的逆矩阵,通常用于把法线从模型空间转换到视角(观察)空间 ,也可用于得到UNITY_MATRIX_MV的逆矩阵
unity_ObjectToWorld当前模型转空间矩阵,通常用于把顶点/方向向量从模型空间转换到世界空间
unity_WorldToObject当前世界转模型矩阵,通常用于把顶点/方向向量从世界空间转换到模型空间

矩阵变换变量中给出了转置矩阵和逆矩阵的变量,由此可见正交矩阵的性质对于我们的计算是非常重要的。而一般而言,我们在这里求解的方阵 M M M M M M指四阶的齐次变换矩阵的左上的三阶矩阵,也就是只包含旋转和缩放的部分),如果只包含旋转和统一缩放(不平移),那么大概率就是正交的,为了满足正交条件,还需要除以统一缩放倍率k,即 M = 1 k M ′ M = \frac{1}{k}M' M=k1?M,其中 M ′ M' M是正交矩阵。

上表也没有给出 ( M V ) ? 1 (MV)^{-1} (MV)?1,实际上只需要对UNITY_MATRIX_IT_MV变量求转置矩阵即可求出该逆矩阵了。

在这里插入图片描述

摄像机和屏幕参数

变量名类型描述
_WorldSpaceCameraPosfloat3相机在世界空间的位置
_ProjectionParamsfloat4x = 1.0(如果当前使用翻转投影矩阵渲染则为-1.0),y是相机的近平面y=Near,z是相机的远平面,z=Far,w是1.0 / Far +1.0(参考NDC映射公式)
_ScreenParamsfloat4x=width,y=height,z=1.0/width+1.0,w=1.0+1.0/height,其中width和height分别是该摄像机的渲染目标(render target)的像素宽高(参考视口映射公式)
_ZBufferParamsfloat4用于线性化Z缓冲区的值。x = (1 - Far /Near),y = (Far/Near)、z = (x /Far)和w = (y /Far)
unity_OrthoParamsfloat4x是正交相机的宽度width,y是正交的相机的高度height,z是未使用的,为正交的相机时w为1.0,透视相机时w为0.0
unity_CameraProjectionfloat4x4该摄像机的投影矩阵
unity_CameraInvProjectionfloat4x4该摄像机投影矩阵的逆矩阵
unity_CameraWorldClipPlanes[6]float4该摄像机的6个裁剪平面在世界空间下的等式,按如下顺序:左、右、下、上、近、远裁剪平面

答疑

使用3x3还是4x4的变换矩阵

对于线性变换来说,3x3的矩阵就可以表示旋转和缩放关系了,之所以要使用4x4的矩阵,是为了表示包含了平移的仿射变换,放在是在四维齐次坐标空间则是线性变换,而只需将w分量设为1就不会对向量在三维的映射有影响。当然,不设为1也可以通过齐次除法映射回三维。

Cg中的矢量和矩阵类型

UnityShader中使用Cg作为着色器编程语言,Cg中变量类型有很多,本书只介绍如何进行数学运算

float

float类型包括常见的float3,float4,float3x3,float4x4 ,从命名不难看出,前二者代表了向量(也可以用于表示1xn的行矩阵和nx1的列矩阵,这取决于运算的种类和在运算中的位置),后二者代表矩阵,而数字代表了维度。

在这里插入图片描述
m u l ( M , v ) = = m u l ( v , t r a n p o s e ( M ) ) mul(M,v)==mul(v,tranpose(M)) mul(M,v)==mul(v,tranpose(M))实际上就是公式 M V = V T M T MV=V^TM^T MV=VTMT(之所以没对V进行转置是因为float4类型既可以当作行矩阵,也可以当作列矩阵)

通常情况下,我们对于向量float3和float4都是直接右乘的。

需要注意Cg对矩阵类型的元素的初始化和访问顺序。在Cg中,对float4x4等类型的变量是按照行优先的方式进行填充的。如果我们直接使用 ( 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 ) (1,2,3,4,5,6,7,8,9) (1,2,3,4,5,6,7,8,9)去填充一个3x3的矩阵,那么则会按照次序逐行进行填充,得到:

[ 1 2 3 4 5 6 7 8 9 ] \begin{bmatrix}1&2&3 \\ 4&5&6 \\ 7&8&9 \end{bmatrix} ?147?258?369? ?

同样的,当访问Cg中矩阵元素时,也是按行来进行索引的。
在这里插入图片描述

在Unity的API中提供了一种矩阵类型Matrix4x4,这种矩阵是按列填充的

Unity中的屏幕坐标:ComputeScreenPos/VPOS/WPOS

在编写Shader的时候,我们有时候希望能够获得片元在屏幕上的像素位置。
在顶点/片元着色器中,有两种方式来获得片元的屏幕坐标:

  • 一种是对片元着色器的输入声明VPOS或者WPOS语义。VPOS是HLSL中对屏幕坐标的语义,而WPOS是Cg中对屏幕坐标的语义(那我还是推荐VPOS吧,比较Cg已经g了),二者在Unity Shader中是等价的。我们可以在HLSL/Cg中通过语义的方式来定义顶点/片元着色器的默认输入,而不需要自己定义输入输出的数据结构。

我们可以这样写:
在这里插入图片描述

VPOS/WPOS的语义定义的输入是一个float4类型的变量。我们知道这个声明为VPOS的变量sp回拥有xy值,代表了屏幕空间的像素坐标。例如屏幕分辨率为400*300,则x的范围为[0.5,400.5],y为[0.5,300.5] ,之所以像素坐标不是整数,是因为图形API们认为起点像素的中心值是0.5,0是边缘值。

那么,它的z分量0和w分量1代表了什么?z=0代表了在摄像机的近裁剪平面Near处,反之z=1代表了在远裁剪平面Far处。

如果使用的是正交投影,那么w恒为1,若使用透视投影则w分量的范围是 [ 1 N e a r , 1 F a r ] ( 根据投影矩阵变换再除以变换矩阵的 w 分量后得到 ) [\frac{1}{Near},\frac{1}{Far}](根据投影矩阵变换再除以变换矩阵的w分量后得到) [Near1?,Far1?](根据投影矩阵变换再除以变换矩阵的w分量后得到)

我们最后要把屏幕空间除以屏幕分辨率来得到视口空间(viewport space) 的坐标,视口坐标很简单,只需要把屏幕坐标归一化即可,这样屏幕左下角是(0,0),右上角是(1,1)

如果已知屏幕坐标,只需把xy值除以屏幕分辨率即可。

  • 另一种方法就是使用Unity提供的ComputeScreenPos 函数。该函数再UnityCG.cginc中被定义(有这么好的东西不早说),通常用法是:首先再顶点着色器中将ComputeScreenPos的结果保存在输出结构体中,然后再片元着色器中进行一个齐次除法运算后得到视口空间下的坐标,例如:
    在这里插入图片描述

上面说的两种方法效果一样,观察上述代码,不难发现其实就是先对顶点应用了MVP变换得到齐次裁剪坐标,然后应用ComputeScreenPos方法得到屏幕坐标,最后将屏幕坐标进行齐次除法得到视口空间坐标。(ComputeScreenPos的方法名听起来是直接获取屏幕坐标,但不是这样,我们依旧需要进行齐次除法)

在这里插入图片描述

上图就是我们这段代码的结果,将归一化的坐标值映射为RGB值,x对应R,y对应G

Unity之所以依旧需要我们对ComputeScreenPos进行齐次除法,而不是自己完成,是由于ComputeScreenPos是在顶点着色器中使用的,如果在顶点着色器中直接使用齐次除法会影响顶点信息的插值,插值结果就会不准确,原因在于插值必须是线性的,而只要在齐次空间下插值才是线性的,若在投影空间中则是非线性的。

虽然我们视口坐标是二维的,只包含了xy,但是上述代码中,顶点着色器的输出结果是自定义的vertOut结构体,存储了四维向量。因此依旧保留了z和w值。

使用该方法,同样的,x,y的范围在 [ 0 , 1 ] [0,1] [0,1]

如果使用的是正交投影,z值范围为 [ ? 1 , 1 ] [-1,1] [?1,1] ,那么w恒为1;
若使用透视投影则z值范围为 [ ? N e a r , F a r ] [-Near,Far] [?Near,Far] , w分量的范围是 [ 1 N e a r , 1 F a r ] ( 根据投影矩阵变换再除以变换矩阵的 w 分量后得到 ) [\frac{1}{Near},\frac{1}{Far}](根据投影矩阵变换再除以变换矩阵的w分量后得到) [Near1?,Far1?](根据投影矩阵变换再除以变换矩阵的w分量后得到)

文章来源:https://blog.csdn.net/milu_ELK/article/details/135596726
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。