游戏中的动态阴影(上)
- 作者:admin
- /
- 时间:2023年04月24日
- /
- 浏览:2286 次
- /
- 分类:厚积薄发
阴影对于提高游戏真实感非常重要,简单总结下游戏中的阴影实现。
先来看下阴影的组成部分,我们可以将阴影大致分成两个部分:全影(Umbra)和半影(Penumbra)。半影区域就是阴影的过渡区,也就是软阴影,有半影的阴影过渡时,视觉效果会好很多。
阴影的组成部分
对于静态的场景,我们可以选择将阴影烘焙到Lightmap中,或者直接画在贴图上。这篇文章,我们主要来介绍下动态阴影的相关技术,因为阴影是实时渲染中比较重要的技术,实现的方式也非常多。本篇文章,尽量覆盖到各种常用的阴影渲染技术。
一、简单的手绘假阴影
在手游或者2D游戏中经常能看到这种做法,对于动态的角色,将阴影做成一张贴图,然后贴到脚下的地面上,虽然是很简单的形式,也能极大地增强真实感。
简易的阴影
二、平面投射阴影
1. 平面投射阴影的计算
平面投射阴影,就是将需要投射阴影的物体再渲染一次,投射到地面上,来产生阴影。根据平面的位置,我们可以计算出一个投射的矩阵,直接将物体的坐标变换到平面上。
我们先来看简单的情况,如下图左边所示将阴影投射到x轴上的情况,我们在光源l的照射下,需要从点v投射阴影到点p,根据三角形相似原理,我们可以简单地得到:
相应地,我们还可以算出z轴上的坐标为:pz =(lyvz-lzvy)/(ly-vy) ,将结果整理成投影矩阵为:
这样可以通过矩阵计算投影坐标为:p=Mv 。
现在,我们看上图中右边这种更加一般的情况,在这种情况下,我们同样可以根据三角形相似原理,推导出投射阴影的坐标变换方程为:
从v点映射到p点:
令p=Mv推导后写成矩阵的形式:
如果是平行光源,计算的方式也是大致相同,并没有特别的难度。
在进行渲染时,我们可以选择先来渲染阴影,将投射阴影的物体,经过上述矩阵的变换到平面上,然后得到没有光照的黑色地面,此时同时把深度写入。然后再正常渲染地面和投射阴影的物体,为了使地面和阴影之间不会冲突,此时可以为深度值添加一些偏移。
添加偏移的方式可以直接通过图形API来添加,比如OpenGL中的glPolygonOffset和DirectX中的DepthBias设置。当然,你也可以选择在绘制阴影时添加偏移,绘制地面时正常绘制,最终的结果都是相同的。后面我们讲到的各种阴影技术,经常会用到添加偏移(Bias)的技术。
另外一种安全的做法是,先正常渲染地面,然后渲染地面上的阴影,渲染阴影时将深度测试关闭,就不会产生深度冲突的问题。最后再渲染投射阴影的物体,这样可以防止阴影投射到非地面的区域。
如果接受阴影的地面不是一个无穷大的平面,则可能需要通过Stencil Buffer标记出需要接受阴影的部分,这样可以只让阴影产生在需要产生的平面上。
另外一个需要注意的,是如下图所示的情况,在进行计算时,需要保证投射阴影的物体位于光源和接受阴影的地面之间,否则就会出现错误的阴影效果。
右边的情形下不应该绘制出阴影
总的来说,这种直接投射阴影的方式,简单直接,适合直接投射在平面上的阴影。目前在手机游戏中,仍然有广泛的应用。
这种直接投射的阴影无法实现软阴影效果。而且由于我们是先渲染出的地面,再将影子的颜色乘以地面的颜色,这样其实并不是完全符合阴影产生的原理。
我们知道,阴影是由于地面没有受到光照而产生的,如果直接将地面的颜色乘以阴影,可能会产生不正确的阴影效果,特别是地面上有高光效果时。这类阴影叫做调制阴影(Modulated shadow),相对普通的阴影,开销要小一些。
游戏中的平面投射阴影
2. 借助Texture的投射阴影
上面我们说到的投射阴影,是直接渲染到被投射的平面上,这样我们就无法实现软阴影的效果,因此我们这里将阴影先保存在一张贴图中,再从贴图中投射到平面上。这样还可以先得到阴影图,再渲染地面,得到正确的阴影效果。
和前面的直接投射相比,这种方式因为中间经过了一层转变,如果保存阴影的贴图分辨率很低,就可能会造成投射出来的结果有锯齿感。
这样,我们就可以将贴图中的阴影先进行边缘模糊,再进行投射,就可以非常方便地得到软阴影效果。
投射阴影实现的软阴影,先将阴影投射到贴图中,然后进行模糊,再投射至平面,实现软阴影效果
为了提升运行效率,我们还可以将多个物体的Texture打包到一个Shadow Atlas中,这样每个物体的投射阴影,占用整个大贴图的一部分。如果光源和投射阴影的物体都没有改变,我们甚至可以不用更新阴影,实现帧间阴影的复用。
三、Shadow Volume阴影
Shadow Volume以前是一种非常流行的阴影实现方案,目前在游戏中也有一定的应用,特别是后面我们将要讲到的PerObject阴影,因此了解其原理是非常重要的。Shadow Volume需要依赖Stencil Buffer来进行实现。
1. Shadow Volume
Shadow Volume就是从光源沿着模型边缘拉伸至无限远处加上前盖后盖形成的形状。可以说,位于Shadow Volume内部的物体,在渲染时具有阴影,在Shadow Volume外部的物体,在渲染时没有阴影。
shadow volume
2. ZPass算法
Shadow Volume阴影的原理就是取一条从视点到目标点的线,每次进入Shadow Volume,Stencil模板计数加一,每次离开计数减一,这样计数为0的部分就是无阴影的地方,计数不为0的地方就是有阴影的地方。
Shadow Volume的实现需要两个Pass,第一个Pass是标记具有阴影的区域,第二个Pass是进行阴影渲染。
第一个Pass,从视点渲染Shadow Volume几何体,屏幕中被Shadow Volume覆盖的区域,就是所有可能产生阴影的位置。我们这里使用Stencil Buffer来标记出实际具有阴影的位置:开启Z-Test,设置Stencil模式为正面部分+1,背面部分-1。这样渲染完成后,Stencil Buffer为0的部分就是无阴影的地方,Stencil Buffer中不为0的部分就是有阴影的地方。
ZPass的原理
第二个Pass,同样也是渲染Shadow Volume的几何体,不过此时直接关闭深度测试,使用模板测试,直接在上一步中标记出的位置渲染出阴影。
3. Z-Fail算法
ZPass算法有个缺陷,当摄影机在Shadow Volume中的时候,就会产生错误的结果。
当摄影机位于Shadow Volume中时,ZPass标记阴影区域失效
所以就有了Z-Fail的算法,Z-Fail算法和ZPass算法类似,只是改成从物体背面计数,在Z-Test fail的几何体部分,在进入Shdow Volume时计数-1,离开时计数+1,这样就可以规避这个缺陷。
使用Z-Fail算法,标记处正确的阴影位置
不过一般来说Z-Fail算法普遍要比ZPass算法慢,因为从背面渲染Shadow Volume,通常会覆盖更多的像素点。
因此在实践中,我们可以先做一个摄影机是否位于Shadow Volume中的判断,来决定使用ZPass或者是Z-Fail算法来进行标记阴影区域。
4. 生成阴影体的步骤
有一种最常见的生成Shadow Volume的方法,不过这种方法要求目标模型是封闭的多边形网格(没有空洞、裂隙、自相交)。
分为三部分:front capping 前盖-> back capping 后盖-> silhouette 轮廓拉伸成的侧面
front capping就是取模型中面向光源的三角面,方向判断可以通过判断面法线和光源方向的乘积的正负值来判断。
back capping就是取模型中背向光源的面,沿光源方向拉伸到无穷远处。
silhouette是判断两个临接面与光源方向不同的边,若认为是轮廓边,则将每条边扩展拉伸到无穷远处形成一个四边形面。
5. 在无穷远出的渲染
如何表示无穷远处的点?使用齐次坐标将w分量置为0,xyz表示方向即可。
如何避免图元在摄影机far clip plane外被裁剪掉?
一种方法是使用GL_DEPTH_CLAMP_NV扩展,将far plane外的点clamp到裁剪空间中。不过这个方法好像是只适用于OpenGL和NVIDIA显卡。
另外一种方法是稍微修改下摄影机的裁剪矩阵,将far plane设置为无穷远。
普通摄影机矩阵
变成下面这样:
远裁面在无穷远处的摄影机矩阵
当然精度或有微乎其微的减少。
6. 适用于非封闭模型的方法
把模型分成两部分,一部分是面向光源的面,一部分是背向光源的面,分别进行拉伸生成Shadow Volume,就可以支持非封闭模型。缺点是原来的轮廓边相当于生成了两次,造成性能浪费。
左边是面向光源面,右边是背向光源面,两个加在一起形成正确的结果
7. 使用Geometry Shader生成Shadow Volume
使用GS可以将生成Shadow Volume的工作移交给GPU,不过必须用TRIANGLE_STRIP的方式来输入模型。
使用GL_TRINGLES_ADJACENCY_EXT模式来向GS中输入三角形图元,就可以获取三角形的邻接面,以此在GS中进行轮廓边判断、输出Shdow Volume等操作。
Geometry Shader中输入的顶点
四、Shadowmap-当前最主流的方式
1. Shadowmap的原理
是当下应用最广泛最常见的方法,Shadowmap的使用,需要两个步骤。
假设我们现在要渲染带阴影的场景如下:
步骤1:从光源处出发,向光照的方向看去,来构造出光照空间。然后在光照空间,我们渲染需要产生阴影的物体,此时将深度写入到Z-Buffer中,得到保存最近处物体的深度值的Shdowmap。
步骤2:然后我们再次正常渲染物体,在渲染时,我们根据渲染物体的世界坐标,变换到上一阶段的光照空间坐标,再计算出该点在Shadowmap中的深度值并进行比较,如果相对光源的距离比Shadowmap中的深度要大,就说明该点处在阴影中,否则就说明不在阴影中。
下图显示了整个Lightmap工作的流程:
Shadowmap计算阴影的大致过程
对于锥形光源,我们只需要沿着光照方向生成Shadowmap。对于类似太阳光的平行光源,我们就需要使用正交投影来进行计算深度,而且投影体的空间范围,需要包含我们的视锥空间。如果是点光源,就会更加复杂一点,为了能保存各个方向的深度值,我们一般需要使用Cubemap 。如果将一个物体进行六次渲染,每次渲染深度到每个面,那么渲染深度的开销就会比较大,因此我们一般会使用RenderTargetArray配合Gemotry Shader,一次性将一个物体的深度,同时写入到六个面上。
2. Light Space Frustrum的计算
Shadowmap的效果,一般会非常依赖于Shadowmap分辨率的大小和Z-Buffer的精度。因此我们要尽量提高Shadowmap的精度。
如果直接使用整个场景的AABB转化到Light Space,肯定是不行的,这样会造成很多不需要的阴影投射计算:
过大的Light Space边界
通常我们会使用下面的方式来计算Light Space Furstrum的边界大小。将世界空间视锥的八个顶点,变换到光照空间,算出在光照空间下,最远和最近的z值,并计算出AABB边界:
不过,这样也可能会造成另外一个问题,就是当摄影机的View Frustrum很小时,造成计算出来的Light Space Frustrum非常小,无法正确地投射所有需要投射阴影的物体。
因此我们还会根据整个场景的AABB空间,对得到的Light Space Frustrum进行扩展,使其能否覆盖到可能产生阴影的物体。当然,为了防止Light Space Frustrum的Near Plane 和Far Plane的值相差过大,我们还会在光照中设置一个最大阴影距离,当阴影投射物体,超出这个最大距离后,就不再投射阴影,来提高阴影的精度。
正确的计算方式
3. Shadow Bias处理自阴影走样
如下图所示,在进行阴影计算时出现了Self-shadow Aliasing/Shadow Acne,在计算自身的阴影时,因为在Shadowmap中存储的深度值,和物体自身的深度是相同的。因为在写入 Shadowmap时,我们计算的是Shadowmap像素中心点的深度值,这样在进行深度采样时,由于Shadowmap的精度限制,就会使比较的深度值产生误差,造成错误的渲染效果。
自阴影走样,右边是加了Bias的效果
一种常见的解决自阴影误差的方式,是使用Bias Factor,对采样时的深度值,沿着光照的方向进行偏移。偏移的值可以是一个常量,这样计算起来比较方便,但是可能会在斜平面上继续产生误差,使用常量时叫做Constant Bias。
下图左边展示了Shadow Acne出现的原因,黑色的竖线代表Shadowmap中像素点的位置。左边是未添加Bias的情况,当我们在彩色的位置点进行比较深度时,其实采样到的深度是旁边的竖线处x标记位置的深度,可以看出,绿色点的深度测试是正确的,蓝色和橙色的深度测试是错误。下图中间是使用了Bias的情况,将深度值沿着光照方向进行偏移固定的距离。这样绿色和橙色的点形成了正确的深度值,但是由于偏移的值比较小,蓝色的点的阴影计算,仍然是错误的。
左:出现Shadow Acne的原因;
中:使用Constant Bias;
右:使用Slope Scale Bias
我们发现,在斜面角度较大时,一个固定的偏移值就不再适用了,因此一个常见的改进,就是根据斜面角度来改变偏移值,叫做Slope Scaled Depth Bias / Slope Bias。如上图右边所示,可以看出所有的点的阴影计算结果都是正确的。
设平面法线和光照方向的夹角为θ,视锥大小为frustrumSize,Shadowmap的大小为
shadowmapSize,考虑到我们需要半像素的偏移,这样我们可以计算出需要的Slop Bias的偏移值为:
不过我们可以注意到,这个偏移值是和tan(θ)成正比的,这样的话,当θ趋近于90度时,偏移值是趋近于无穷大的,因此我们需要为偏移值设置一个最大值。
在实际游戏引擎实践中,我们常常需要结合两种Bias来使用,这样来达到较好的效果。
这两种Bias都可以通过图形API硬件来实现。例如在DX11中,我们可以在OutputMerge阶段中,通过参数指定两种Bias的值[1]:DepthBias和SlopeScaledDepthBias,这样总的Bias计算方式为:
Bias = (float)DepthBias * r + SlopeScaledDepthBias * MaxDepthSlope;
我们还可以设置DepthBiasClamp的值,防止计算出的Bias值过大:
Bias = min(DepthBiasClamp, Bias)
另外一种常用的替代Slope Scaled Depth Bias的方案是Normal Offset Bias,将阴影的计算位置沿着物体表面的法线偏移,通过计算我们可以算出需要偏移的距离为:
相对于Slope Scaled Depth Bias,这种方式的一个优点是不用担心θ趋近于90度时,整个偏移值趋近于无穷大。
UE4中,使用的Constant Bias + Slope Scaled Depth Bias:
Unity中,使用的是Constant Bias + Normal Offset Bias:
当然,我们的Bias值也不能设置得过大,否则会出现漏光等问题,也叫做Peter Panning。
Bias值太大导致的Peter Panning
为了保证这种Bias的方式能正确地解决深度冲突。我们应尽量保证物体几何模型是正确的,保证正反面朝向是对的,尽量保证模型封闭,且避免使用太薄的物体模型。
添加Bias可以是在生成Shadowmap阶段完成,也可以在阴影计算阶段,也就是生成Shadowmap时。在Vertex Shader中通过反向添加Bias的方式来偏移计算处的Shadowmap深度值,这样可以节省一些运行开销,且可以简化阴影的计算,这样在采样阴影时,就无需考虑计算偏移的问题。
大部分情况下二者得到的效果是基本接近的,不过在Shadowmap生成阶段添加偏移这种方式也有一些瑕疵:
- 不够灵活,所有点的偏移值完全相同,意味着无法根据情况灵活调整Bias值,比如在PCF采样软阴影时,只能提前给出比较大的Bias值,而无法根据PCF Radius的大小灵活调整;
- 和Normal Offset Bias,在光照角度比较小的时候,会导致渲染结果错误[2],Unity中的阴影就有这样的缺陷。
在光照角度较小时,Unity URP的错误阴影效果
还有一种比较少见的解决自阴影的方式,是将物体背面的深度写入到Shadowmap,进行深度测试时,就不会出现深度冲突。但是这种方式有很大限制,要求使用的模型必须是正确封闭的,且正反面没有错误。而且如果物体模型很薄,导致前面和背面深度几乎相等,这种方式仍然会失效。因此这种方式不太通用,现在已经很少能见到。
4. 移动平台的Pack
某些旧的移动平台不支持浮点数纹理,这时需要我们将Shadowmap的深度值Pack到RGBA贴图中,Pack和UnPack的公式如下:
//Pack:
vec4 comp = fract(depth * vec4(255.0 * 255.0 * 255.0, 255.0 * 255.0, 255.0, 1.0));
comp -= comp.xxyz * vec4(0.0, 1.0 / 255.0, 1.0 / 255.0, 1.0 / 255.0);
//UnPack:
float depth = dot(texture((m_tex), (m_uv)), vec4(1.0 / (255.0 * 255.0 * 255.0), 1.0 / (255.0 * 255.0), 1.0 / 255.0, 1.0))
这里我们使用的是255作为模来使用,网上也能搜索到使用256作为模的版本。
但是测试结果表明,使用256时精度是不如255的[3],而且还会遇到不同硬件表现不一致的问题,因此强烈建议使用255 作为参数。
五、Shaowmap精度提升
由于Shdowmap的精度限制,我们在渲染中会遇到各种各样的渲染问题。
一种叫做Perspective Aliasing,由于Shadowmap是在Light Sapce中进行计算的,所以在View Frustrum近处观察时,每个像素对应Shaodowmap中Texel的比例就会降低,产生锯齿。
Perspective Aliasing在近处比较明显
另外这一种叫做Projective Aliasing,是在斜面上进行渲染时,Shadowmap精度不足产生的,本质上来说和Perspective Aliasing是相同的。
Projective Aliasing
通常,提升Shadowmap的分辨率可以改善上面两种渲染问题。但是处于性能考虑,我们不会把Shadowmap的分辨率设置的太大,而是使用一些手段,来提高渲染结果的精度。
1. 使用Perspective Warping
这类方法,通过修改光照空间的投影矩阵,来为视锥近处的物体阴影,提供更高的精度。
常见的有这样几种方式,Perspective Shadow Maps(PSM),Light Space Perspective Shadow Maps(LiSPSM)和Trapezoidal Shadow Maps (TSM)。这些修改投影矩阵的方式原理上大致都是相通的,如下图所示,显示了这类方式的原理:
改变计算Shadwomap时的投影方向
就可以为近处提供更高的精度
这类方式虽然使用起来简单,但是有很多无法处理的特殊情况,比如观察方向和光照方向完全相同时,这类方式就完全无法发挥作用。而且在摄影机移动时,这种方式非常的不稳定。
这类方式目前已经被彻底淘汰,这里也就不再深入讲解相关的原理和实现。
2. Cascaded Shadow Maps(CSM)
CSM是目前最常见的提高Shadowmap精度的手段,候也叫做Parallel-Split Shadow Maps。
通常在渲染视角附近的物体时需要更高的Shadowmap精度,而直接生成的Shadowmap往往不符合这个条件,所以将Frustum分割成数个部分,每个部分单独生成一张Shadowmap,最后组合成一张Atlas。
CSM
从理论上来说,使用指数分布的CSM划分方案是最佳的,即满足
f、n是相机的far、near值,n是指数系数。
比如我们取n=3,f=1000。 这样我们划分出来的三级CSM就是:1-10,10-100, 100-1000。
但是如果我们这样来划分,最近处1-10这个范围的一个CSM划分,物体太少,反而会导致Shadowmap空间的浪费。因此在实践中,常常会结合指数划分和其他划分手段来使用,或者直接由用户手动设置相应的比例值。
Unity中的CSM,不同的颜色代表不同的CSM区域
3. Stablize CSM [4]
在使用Shadowmap时,在移动摄影机时,我们经常会遇到阴影闪烁的问题。因为当摄影机移动后,摄影机的View Frustrum会发生改变,同时Light Space的Frustrum会相应改变,就会造成两帧直接的阴影位置不一样,产生闪烁,在没有使用PCF过滤阴影时,会尤其明显。下图显示了这种闪烁的示例,可以看出视角的微小变化,导致阴影产生了剧烈的闪烁:
通常我们会使用Stabilize Cascades来解决这个问题,Stabilize Cascades将相机的移动分成两个部分来处理,分别是相机的旋转和平移。无论相机是如何运动的,都可以分解成沿着视锥中心的旋转和平移。
首先来看绕视锥中心的旋转,当视锥旋转时,因为视锥边界的改变,就会导致计算出来阴影的Light Space Frustrum改变,产生不稳定的结果。要解决这个问题,我们将视锥 Frustrum计算出一个球形的Bounding Volume出来,并用这个球形的Bounding Volume 来算出阴影的Light Space Frustrum,这样当我们的视锥沿着球体中心旋转时,得到的球形Bounding Volume是不变的,算出来的阴影的Light Space Frustrum自然也不会变化。
ab展示的传统的Light Space Frustrum计算过程
cd使用球形BV时的计算过程,在摄影机转动时也是稳定的
从Frustrum生成Bounding Box Sphere,可以使用简单方法求出中心点,算最大半径的方式。也可以使用能得到更加紧凑边界的标准算法[5]。
接下来就是处理摄影机平移的部分了,这一步的处理,就是通过偏移投影矩阵,来保证两帧之间,世界空间中的同一点,能投影到Shaodwmap中的相同相对像素位置上。为了计算方便,我们常常取世界空间中的零点,作为参考点,将世界空间的零点,变换到Shadowmap坐标中,并通过偏移,确保得到的Shadowmap坐标是对齐于某个像素的。对齐过程实现的大致代码如下:
// Create the rounding matrix, by projecting the world-space origin and determining
// the fractional offset in texel space
XMMATRIX shadowMatrix = shadowCamera.ViewProjectionMatrix().ToSIMD();
// 使用零点作为参考点
XMVECTOR shadowOrigin = XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f);
// 将参考点变换到 shadowmap的坐标
shadowOrigin = XMVector4Transform(shadowOrigin, shadowMatrix);
shadowOrigin = XMVectorScale(shadowOrigin, sMapSize / 2.0f);
// 在shadowmap坐标系中,将坐标对齐到整数坐标线上
XMVECTOR roundedOrigin = XMVectorRound(shadowOrigin);
XMVECTOR roundOffset = XMVectorSubtract(roundedOrigin, shadowOrigin);
roundOffset = XMVectorScale(roundOffset, 2.0f / sMapSize);
roundOffset = XMVectorSetZ(roundOffset, 0.0f);
roundOffset = XMVectorSetW(roundOffset, 0.0f);
//应用偏移,得到新的 projection 矩阵
XMMATRIX shadowProj = shadowCamera.ProjectionMatrix().ToSIMD();
shadowProj.r[3] = XMVectorAdd(shadowProj.r[3], roundOffset);
shadowCamera.SetProjection(shadowProj);
在大部分游戏引擎中,Stablize CSM都是默认打开的。不过需要注意的一点是,打开Stablize CSM时,因为阴影的有效范围减少了,所以是会导致阴影精度降低的。在可以保证阴影效果足够软而不会产生闪烁的时候,也可以选择关闭这个功能,来提升阴影的精度。
4. CSM Caching
在使用CSM时,我们常常会遇到CSM开销较大的问题,比如现在使用四级CSM级联,就意味着在生成Shaodwmap时,很多物体需要重复绘制四次。因此有的时候我们会对CSM进行一些优化。
一种方式是降低远处CSM的更新频率。比如在原神的PC版中,共有八级的CSM,前四级是每帧都更新的,后四级是逐帧依次更新的,这样相当于每帧需要更新五级的CSM。
另外一种方式是将CSM中算出的阴影动态缓存,对于静态物体的Shadowmap,是可以实现前后两帧之间的复用的。上一帧中静态物体的Shadowmap,经过一些小小的处理,在当前帧仍然是可用的,对于一些没有覆盖的区域,可以动态来检测,重新绘制生成:
CSM Caching
参考:
[1] https://learn.microsoft.com/en-us/windows/win32/direct3d11/d3d10-graphics-programming-guide-output-merger-stage-depth-bias
[2] https://zhuanlan.zhihu.com/p/370951892
[3] https://aras-p.info/blog/2009/07/30/encoding-floats-to-rgba-the-final/
[4] ShaderX6 Stable Cascaded Shadow Maps
[5] https://zhuanlan.zhihu.com/p/136752363
更多内容,请关注:
游戏中的动态阴影(下)
这是侑虎科技第1380篇文章,感谢作者张亚坤供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)
作者主页:https://www.zhihu.com/people/tc130-52
【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!