zoukankan      html  css  js  c++  java
  • 【Unity Shader】(三) ------ 光照模型原理及漫反射和高光反射的实现

    【Unity Shader】(三) ---------------- 光照模型原理及漫反射和高光反射的实现

    【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现
    【Unity Shader】(五) ------ 透明效果之半透明效果的实现及原理

    本文主要参考了冯乐乐老师的《Unity Shader入门精要 》一书,再加上网上一些参考资料而写。

    笔者使用的是 Unity 2018.2.0f2 + VS2017,书中使用的是 Unity 5.2.1 ,由于版本更新,所以本文的一些shader代码会与原文中有一些差异。建议读者使用与 Unity 2018 相近的版本,避免一些因为版本不一致而出现的问题。

    从宏观上说,渲染包含了两个部分:①决定一个像素的可见性 ②决定一个像素上的光照计算。 而为了方便的计算光照计算,我们会采用光照模型

    本文着重记录的是光照模型的作用原理,因此本文中实现的Shader均不可直接运用于项目之中,而在往后的篇章,我会给出包含了完整光照模型的,可真正使用的Shader

    目录

    一. 我们如何看见事物

    1.1 光源

    1.2 吸收和散射

    1.3 着色

    1.4 BRDF 光照模型

    二. 标准光照模型

    2.1 自发光

    2.2 高光发射

    2.3 漫反射

    2.4 环境光

    2.5 Blinn-Phong模型

    2.6 逐像素光照与逐顶点光照

     三. 在Shader中实现漫反射

    3.1 逐顶点光照

    3.2 逐像素光照

    3.3 半兰伯特模型

    四. 在Shader中实现高光反射

    4.1 逐顶点光照

    4.2 逐像素光照

     

    4.3 Blinn-Phong 光照模型

     五. 便利的内置函数

    六. 总结



    一. 我们如何看见事物

    我们在描述一个物体外貌时时常会说 “这物体是什么什么颜色的”,而在中学时,我们学的物理知识告诉我们,如果你看到这个物体是红色的,那就说明这个物体反射了很多红色光的波长,而吸收了其它颜色的波长。如果一个物体是黑色的,则说明这个物体吸收了大部分的波长。而这种物理现象也是我们在Shader要处理的问题。简单的说,可以分成这3个现象。

    • 光源发射出光线
    • 光线与物体相交,部分光线被吸收,部分光线被散射
    • 摄像机吸收了部分光线,产生图像  

    1.1 光源

    既然有光,那就必定有光源。在渲染中,我们通常把光源当做一个点,用   l  表示光源方向。同时,为了量化光源发射出来的光,我们使用 辐照度(irradiance)来量化光。一般对于平行光,它的辐照度可以通过计算在垂直于  l  的单位面积上单位时间内穿过的能量来得到。

    在计算光照模型时,我们需要知道一个物体表面的辐照度,但是物体表面往往不是和  l  垂直的,所以为了得到这种情况下辐照度,我们可以通过计算光源方向  l  和表面法线   n  之间的夹角的余弦值来得到。而且,这里默认方向矢量的摸都为1

     

     左图中,光垂直照射到物体表面,所以光线之间的垂直距离保持不变;右图中,光是斜照到物体表面,所以光线间距离为 d / cos θθ ,因此单位面积上接收到的光线数目比左图少。

    因为辐照度是和照射到物体表面时光线之间的距离 d / cos θθ 成反比,即与 cos θθ 成正比。 cos θθ 可以用光源方向  l  和表面法线   n 点积得到。

    1.2 吸收和散射

    光源与物体相交一般会有两个结果:散射(scattering)吸收(absorption)

    散射只改变光线方向,不改变光线密度和颜色。而吸收只改变光线密度和颜色,不改变其方向。光想在散射后一般有两种方向:

    ① 散射到物体内部,这种现象称为 折射(refraction)透射(transmission)

    ② 散射到外部,这种现象称为 反射(reflection),对于不透明物体,折射进入物体内部的光线还会继续与内部的颗粒相交,同样会发生折射和反射现象,而此时从物体表面重新射出的光线将具有和入射光线不同的方向分布和颜色。

                                              折射的光线会在物体内部传播,有一部分会重新从表面发射出去

    所以就产生了两种散射方向。为了区分这两种方向,我们在光照模型中使用了不同的部分来计算:

    I . 高光反射(specular)部分表示物体表面是如何反射光线的

    II . 漫反射(diffuse)部分表示有多少光线会被折射,吸收和散射出表面

    我们引入 出射度(exitance)这个概念来描述出射度,而辐照度和出射度是满足线性关系的,而它们的比值就是材质的漫反射和高光反射属性。

    在本文中,我们是假设漫反射部分是没有方向性的,即光线在所有方向上是平均分布的。同时,我们也只考虑某一个特定方向上的高光发射。

     

    1.3 着色

    根据 ① 材质属性(如漫反射,高光反射等属性) ② 光源信息(光源方向,光照强度等),使用一个等式去计算沿某个观察方向的出射度的过程,这个过程就叫做着色(shading)。而这个等式也称为  光照模型  (Light Model),而光照模型也这是本文所重点探讨的。光照模型不止一种,不同的光照模型对应着不同的作用。

    1.4 BRDF 光照模型

    当光线从某个方向照射到一个物体表面时,有多少光线被反射?反射方向有哪些?而 BRDF(Bidirectional Reflectance Distribution Funtion)回答了这些问题。当给定模型表面上的一个点时,BRDF 包含了对该点外观的完整描述。简单的说,BRDF可以从给定的入射光线的方向和辐照度计算出某个出射方向上的光照能量分布。本文涉及的光照模型均是经验模型,并不能真实地反映物体和光线之间的交互。但即便如此,经验模型也被应用了多年。

    二. 标准光照模型

    1973年,著名学者Bui Tuong Phong提出了标准光照模型的基本概念https://en.wikipedia.org/wiki/Bui_Tuong_Phong

    标准光照模型只关注直接光照,简单得说就是 直接从光源发射出来照射到物体表面后,经过一次反射后直接进入摄像机的光线。基本方法是把进入摄像机的光线分成4个部分,每个部分分别有一种分发来计算其贡献度

    •  自发光    用  	extbf{	extit{Cemissive}} 表示,意义为:当给定一个方向时,物体表面会往这个方向发射多少辐射量。 在没有全局光照的情况下,自发光不会照亮周围的物体,而仅仅是自身看起来更亮了。
    •  高光反射  用  	extbf{	extit{Cspecular}} 表示,意义为:物体表面完全镜面反射方向散射多少辐射量。
    •  漫反射   用 	extbf{	extit{Cdiffuse}} 表示,意义为:物体表面会向每个方向散射多少辐射量
    •  环境光  用 	extbf{	extit{Cambient}} 表示,意义为:描述其它所有间接光照

    2.1 自发光

    标准光照模型中,自发光的计算可以直接使用该材质的自发光颜色  	extbf{	extit{Cemissive}} = 	extbf{	extit{ Memissive}}

    2.2 高光发射

    这里所说的高光反射是一种经验模型,并不完全符合现实中的高光发射现象。可以让物体看起来比较有光泽,金属材质是其一。要计算高光发射需要4个参数:①表面法线 ②视角方向 ③光源方向 ④反射方向 。

    而反射方向可以通过一下公式计算

                                                         hat{r} = 2(hat{n} cdot hat{l})hat{n} - hat{l}}}}}

    下图给出了以上4个参数的关系

                         

     利用Phong模型可以计算出高光发射部分

                                      large {color{Red} _{C specular} = (_{C light} cdot _{M specular})max(0,hat{v} cdot hat{r})^{_{Mgloss}}}

     其中 large _{Mgloss} 是材质的 光泽度(gloss)或者称为 反光度(shininess)。而Max函数是为了防止点乘的结果为负数,所以将之截取至0。

    2.3 漫反射

    在漫反射中,视角的位置并不重要,因为反射是完全随机的,因此可以认为在任何反射方向上的分布都是一样的,但是入射光线的角度是重要的。

    漫反射光照符合 兰伯特定律(Lambert's law)反射光线的强度与表面法线和光源方向间的夹角的余弦值成正比

                                      large {color{Red} _{Cdiffuse} = (_{Clight} cdot _{Mdiffuse})max(0, hat{n} cdot hat{l})}

    这里同样要防止点乘为负数的情况,这样可以防止物体被从后面的光源照亮

    2.4 环境光

    环境光通常只是一个全局变量,所有物体都使用这个环境光  	extbf{	extit{Cambient}} = 	extbf{	extit{ Gambient}}

    2.5 Blinn-Phong模型

    和上面的Phong模型相比,Blinn模型避免了反射方向 dot{r} 的计算,而是引入了一个新的矢量 hat{h}

                                                               hat{h} = frac{hat{v} + hat{l}}{left | hat{v} + hat{l} 
ight |}

    然后使用 hat{n}hat{h} 之间的夹角进行计算,而不是 hat{v}hat{r} 之间的夹角。如 图

                                    large {color{Red} _{C specular} = (_{C light} cdot _{M specular})max(0,hat{n} cdot hat{h})^{_{Mgloss}}}

     Blinn模型 和 Phong模型都是经验模型,在实际情况中,有时候Blinn模型更加合适。

    2.6 逐像素光照与逐顶点光照

    上面给出了光照模型的计算公式,而计算光照模型一般有两种方法。

    ① 在片元着色器中计算。此方法称为 逐像素光照 或 Phong着色(Phong shading),以每个像素为基础,得到它的法线(可以对顶点法线插值得到,也可以从法线纹理中采样得到)

    ② 在顶点着色器中计算。 此方法称为 逐顶点光照 或 高洛德着色(Gouraud shading), 在每个顶点上计算光照,然后在渲染图元内部进行线性插值,输出成像素颜色。而顶点数目通常会远小于像素数目,所以逐顶点光照的计算量往往更小。不过,由于逐顶点计算依赖于线性插值如果光照模型中存在非线性插值时(如计算高光反射)就会出现问题。而且,渲染图元内部的颜色总是暗于顶点处的最高颜色值。我们稍后会看到这些情况。

    看了这么久,大家都累了,先休息一下吧

     

     三. 在Shader中实现漫反射

    好了,看了那么多理论,估计各位都差不多有些烦躁了。现在我们来聊聊代码了。

    (再啰嗦一点点 O(∩_∩)O)在上面 2.3 中我们给出了漫反射光照模型的计算方式

                                large {color{Red} _{Cdiffuse} = (_{Clight} cdot _{Mdiffuse})max(0, hat{n} cdot hat{l})}

    其中max函数是为了防止点乘结果为负数,而Cg中提供了这个函数

     

    这是MSDN上对Unity Shader内置函数staurate的解释。好了,理论知识准备完了,我们现在开始搞代码了 !!!

    3.1 逐顶点光照

    (1)在Unity中创建一个场景,该场景默认有一个摄像机和一个平行光,把天空盒置空。

     Window -> Rendering -> LightingSetting

    (2)创建一个Material,命名为DiffuseVertexLevel;创建一个Shader,命名同上,并把它赋给材质;创建一个Capsule,把材质赋给Capsule。

    (3)保存场景,如图。

     

    打开新建的shader,删除里面的代码,并按以下步骤书写代码

    I.  首先给这个shader命名

    II.  然后再 Properties 语块中声明了一个Color类型的属性,以得到和控制材质的漫反射颜色

    III. 再在 SubShader 语块中定义一个Pass语义块,这是因为顶点/片元着色器的代码需要写在Pass语义块里面,而不是SubShader里面。并且,在第一行指明了该Pass的光照模式为向前渲染。LightMode 标签是 Pass 标签中的一种,用于定义该Pass在Unity的光照流水线的角色。而且只有定义了正确的 LightMode ,才能使用一些Unity的内置光照变量。

    IV. 然后 ,书写CG代码片。先定义顶点着色器和片元着色器,并且为了使用一些内置变量,包含一个内置文件

    Lighting.cginc文件可以在   你的Unity安装目录/Editor/Data/CGIncludes下找到

    V. 为了使用 Properties 语块中定义的 _Diffuse ,需要定义一个和该类型匹配的变量。

    这样就得到了计算公式中的参数之一 : 漫反射属性

    VI. 接着定义输入输出结构体

    a2v中,vertex变量可以访问顶点信息;normal变量可以访问顶点法线信息

    v2f中,pos变量负责保存片元着色器中的顶点在裁剪空间中的坐标;color变量负责接收片元着色器中计算出来的光照颜色,不过,color 变量并非必须使用COLOR语义,部分资料也会使用TEXCOORD0语义

    VII. 由于实现的是逐顶点光照,所以,光照计算会放在顶点着色器中

     需要注意的是:

    书中对顶点信息转换至裁剪空间用的是以下代码

    如果你使用这行代码,Unity会提示你以下信息

     

    并自动帮你更改为

     

    版本更新之后,Unity提供了一个内置函数来进行转换空间,而 UnityObjectToClipPos 的函数原型如图

     

    事实上,Unity也提供了一些其它转换的内置函数,稍后会列来部分常用内置函数

    这里对光源方向的计算并不具备通用性,当场景中存在多个光源时,直接使用 _WoldSpaceLightPos() 并不能得到正确的结果。稍后会给出处理复杂的光源类型的方法。

    VIII. 再加上一个简单的片元着色器

    IX. 最后再加上一个“小备胎”,就OK了

    完整的代码:

    Shader "Unity/Custom/01-Diffuse Vertex-Level"
    {
    	Properties{
    		_Diffuse ("Diffuse",Color) = (1,1,1,1)
    	}
    
    	SubShader
    	{
    		Pass
    		{
    			Tags {"LightModel" = "ForwardBase"}
    	
    			CGPROGRAM
    
    			#pragma vertex vert
    			#pragma fragment frag
    			#include "Lighting.cginc"
    
    			fixed4 _Diffuse;
    
    			struct a2v
    			{
    				float4 vertex : POSITION;
    				float3 normal : NORMAL;
    			};
    
    			struct v2f
    			{
    				float4 pos : SV_POSITION;
    				fixed3 color : COLOR;
    			};
    
    			v2f vert(a2v v)
    			{
    				v2f o;
    				//保存顶点在裁剪空间中的坐标信息
    				o.pos = UnityObjectToClipPos(v.vertex);
    				//得到环境光部分
    				fixed3 ambient =  UNITY_LIGHTMODEL_AMBIENT.xyz;
    				//把顶点法线转换到世界空间中
    				fixed3 worldnormal = normalize(mul(v.normal,(float3x3)unity_WorldToObject));
    				//光源方向归一化
    				fixed3 worldlight = normalize(_WorldSpaceLightPos0.xyz);
    				//光源颜色和强度、漫反射属性、顶点法线、光源方向,利用公式计算
    				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldnormal,worldlight));
    
    				//环境光 + 漫反射光 = 最终光照结果
    				o.color = ambient + diffuse;
    				return o;
    			}
    
    			fixed4 frag(v2f i) : SV_Target
    			{
    				return fixed4(i.color,1.0);	
    			}
    
    
    			ENDCG
    
    		}
    
    	}
    
    	FallBack "Diffuse"
    
    }

     保存一下,回到Unity查看效果

    效果如图

     对于一些细分程度较高的模型,逐顶点光照的效果已经不错了,不过对于细分程度低的模型就会出现视觉问题。我们可以看到在向光面和背光面交界处的棱角十分明显。而为了解决这些问题,我们可以使用逐像素光照。

    3.2 逐像素光照

    逐像素光照与逐顶点光照的差异主要是计算光照的地方不一样,所以只需对上面逐顶点光照的代码进行部分修改便可。

    (1)新建Material和Shader,命名为 DiffusePixelLevel。

    (2)将逐顶点的代码复制进去,然后进行修改。

    I. 修改输出结构体v2f

     

    其中,worldnormal 使用 TEXCOORD0,保存转换至世界空间的顶点法线信息。

    II. 顶点着色器不需要计算光照

    III. 片元着色器进行光照计算

    因为大部分代码是一样的,这里就不贴完整代码了。

     保存。回到Unity查看效果

    效果如图

     

    对比可以看到,逐像素光照实现的效果比逐顶点更加平滑。不过即便如此,在光照无法到达的区域依旧是全黑的。如果添加了环境光就可以达到非全黑的效果,但是此时,模型背光面明暗会一样。而针对这一情况所提出的技术就是 半兰伯特(Half Lambert)光照模型 

    3.3 半兰伯特模型

    前面使用的光照模型也称为兰伯特光照模型。而广义上的半兰伯特光照模型的公式如下

                                        large {color{Red} _{Cdiffuse} = (_{Clight} cdot _{Mdiffuse})(alpha (hat{n cdot hat{l}} + eta ))}

    对点积的结果进行 α 倍的缩放,再加上 β 的旋转。而绝大数情况下,α 和 β 都是0.5,所以公式也可以写成

                                         large {color{Red} _{Cdiffuse} = (_{Clight} cdot _{Mdiffuse})(0.5(hat{n cdot hat{l}} + 0.5 ))}

     对于背光面,在兰伯特模型中,点积结果为负数,然后均会被映射到 0 处;而在半兰伯特模型中,不同的点积结果会映射到不同的值,就会产生明暗变化。

    不过需要注意的是,半兰伯特模型没有物理根据,仅仅是一个视觉加强技术。

    I. 同样新建Material和Shader,命名为Half-Lambert。

    II. 把逐像素光照模型的代码复制进去,只进行一下修改

     

    保存。查看效果

     

                                 逐顶点光照模型                      逐像素光照模型                            半兰伯特光照模型

    可以看到,半兰伯特模型更加平滑,明暗变化也更明显。

    四. 在Shader中实现高光反射

    回想一下,上面所说的高光反射部分的计算公式

                                     large {color{Red} _{C specular} = (_{C light} cdot _{M specular})max(0,hat{v} cdot hat{r})^{_{Mgloss}}}

    这里同样需要4个参数:① 入射光线的颜色和强度 ②材质高光反射系数 ③视角方向 ④反射方向

    而反射方向可以由表面法线光源方向计算得到

                                                            large {color{Blue} hat{r} = dot{l} - 2(hat{n} cdot hat{l})hat{n}}

    而 Cg 也提供了计算这条公式的函数

     

    4.1 逐顶点光照

    I.  新建一个Material 和 Shader,步骤与代码结构与漫反射相似

    II. 定义 Properties 语义块

     

    其中 _Specular 属性控制高光反射,_Gloss 属性控制高光区域的大小

    III. 为了使用 Propreties 中的属性,需要定义相匹配的变量

     

    IV. 输入输出结构体与漫反射逐顶点光照模型的一样

    V. 在顶点着色器中计算光照,与漫反射类似

     

    需要注意的是:reflect 函数的入射方向是由光源指向交点的,所以这里的参数要取负

    VI. 再加上片元着色器和回调Shader

    完整代码:

    Shader "Unity/Custom/01-Specular Vectex---Level" 
    {
    	Properties 
    	{
    
    		_Diffuse ("Diffuse",Color) = (1,1,1,1)
    		_Specular("Specular",Color) = (1,1,1,1)
    		_Gloss("Gloss",Range(8.0,256)) = 20
    
    	}
    	SubShader 
    	{
    		Pass
    		{
    			Tags { "LightMode"="ForwardBase" }
    
    
    			CGPROGRAM
    			#pragma vertex vert
    			#pragma fragment frag
    
    			#include "Lighting.cginc"
    
    			fixed4 _Diffuse;
    			fixed4 _Specular;
    			float _Gloss;
    			
    			struct a2v{
    
    				float4 vertex : POSITION;
    				float3 normal : NORMAL;
    
    			};
    
    			struct v2f{
    
    				float4 pos : SV_POSITION;
    				fixed3 color : COLOR;
    
    			};
    
    			v2f vert(a2v v)
    			{
    				v2f o;
    				//顶点信息转换至世界空间
    				o.pos = UnityObjectToClipPos(v.vertex);
    				//得到环境光部分
    				fixed3 ambient =  UNITY_LIGHTMODEL_AMBIENT.xyz;
    				//顶点法线转换至世界空间
    				fixed3 worldnormal = normalize(mul(v.normal,(float3x3)unity_WorldToObject));
    				//光源方向
    				fixed3 worldlight = normalize(_WorldSpaceLightPos0.xyz);
    				//漫反射部分
    				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldnormal,worldlight));
    				//计算反射方向
    				fixed3 reflectDir = normalize(reflect(-worldlight,worldnormal));
    				//计算视角方向
    				fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld,v.vertex).xyz);
    				//得到高光发射部分
    				fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir,viewDir)),_Gloss);
    				
    				o.color = ambient + diffuse + specular;
    				return o;
    			}
    
    			fixed4 frag(v2f i) : SV_Target
    			{
    				return fixed4(i.color,1.0);	
    			}
    			ENDCG
    
    		}
    
    	}
    	
    	FallBack "Specular"
    }

    保存。回到Unity查看效果

     

     可以看到,得到的效果棱角十分明显。上文也提过这个问题,因为高光发射部分的计算是非线性的,而顶点着色器的计算是线性插值的,所以便会产生这种视觉问题。

    4.2 逐像素光照

    和漫反射类似,逐像素光照需要改动的地方并不多,所以这里只贴出改动的代码块

     保存,查看效果

    可看到明显的平滑了许多。至此,我们就实现了一个完整的 Phong 光照模型。

    如果你能从本文开头心平气和地看到这里,并且中途没有想锤我的想法,那么恭喜你初步掌握了 Phong 模型。先休息一下,最后还有一点知识点需要你坚持下去

     

    好了,休息回来,我们学习最后一部分。

     

    4.3 Blinn-Phong 光照模型

    本文前面已经提出过这个概念,回想一下 Blinn-Phong模型 的计算公式

                                    large {color{Red} _{C specular} = (_{C light} cdot _{M specular})max(0,hat{n} cdot hat{h})^{_{Mgloss}}}

     实现这个光照模型同样不难,步骤同上。取 4.2 中的代码,然后修改一小部分,这里只贴出修改的部分

    效果对比:

     

     可以看到 Blinn-Phong 光照模型的高光反射区域更大更亮一些。

     五. 便利的内置函数

    在前文的代码中,我们看到了不少求某某方向的代码实现。而我们的环境中是默认只有一个平行光的。如果光源类型复杂,我们就需要先判断光源类型再计算,而且计算过程参数也不少,就容易出现错误。所幸的是,Unity提供了许多内置函数来帮我们实现一些复杂的计算。这里,我列举出一些常用的函数及其作用,更多的内置函数,大家可以去UnityCG.cgingc 文件中查看。

          float3 WorldSpaceViewDIr (float4v)                        输入一个模型空间的顶点位置,返回世界空间中从该点到摄像机的观察方向
          float3 WorldSpaceLightDir (float 4 v) 仅可用于前向渲染。输入一个模型空间中的顶点位置,返回世界空间中从该点到光源的光照方向
    float 3 UnityObjectToWorldNormal (float3 norm)                                           把法线方向从模型空间转换到世界空间
        float 3 UnityObjectToWorldDir (float3 norm)                                            把方向矢量从模型空间转换到世界空间

     现在我们来改写 Blinn-Phong 中的代码

     

    得到正常效果:

    其他的还有许多便利的内置函数这里就不一 一列举出来了,大家可以在UnityCG.cginc中看到。

    六. 总结

    本文列出的Shader均是不完整的Shader,不可以直接运用于项目之中,会缺乏光照衰减等现象,只是为了学习光照模型的原理。且本文所列出的光照模型都是经验模型,并不能真正代表现实中的光照情况,但其仍在实时渲染领域被引用多年。

    因为计算机图形学第一定律:如果它看起来是对的,那么它就是对的。

    最后说一下,可能有不少人会觉得本文啰嗦。不过为了学习shader,我们必须要学会相关的理论知识,我本人也不过是一名初学者,能提炼的东西实在有限,不过它的确可以帮助你了解 shader 中的光照计算。

    码字不易,累死爸爸了~ o(* ̄︶ ̄*)o

    本文实现的 Shaders

  • 相关阅读:
    EF性能优化-有人说EF性能低,我想说:EF确实不如ADO.NET
    MiniProfiler工具介绍(监控EF生成的SQL语句)--EF,迷你监控器,哈哈哈
    C# 数据库并发的解决方案(通用版、EF版)
    锁、C#中Monitor和Lock以及区别
    LINQ 如何动态创建 Where 子查询
    C# Npoi 实现Excel与数据库相互导入
    MVC ActionResult派生类关系图
    如何构造树状 JSON 数据 JSON-Tree
    如何构造分层次的 Json 数据
    如何使用 GroupBy 计数-Count()
  • 原文地址:https://www.cnblogs.com/BFXYMY/p/9683608.html
Copyright © 2011-2022 走看看