D3D基础 – 光照,材质与着色
感谢:http://www.windameister.org/blog/2010/09/18/d3dbasic-lighting-material-shading/
初接触d3d时,相信许多初学者和我一样,虽然对3D实时渲染的原理有所了解,但是却对整体的管线结构有所困惑,包括可能许多做3d游戏的程序员,由于常常采用封装好的引擎做上层逻辑开发,因此对底层的架构也未必了解得十分透彻。
长久以来,对3d图形学的底层技术,我一直也是只了解个只鳞半爪而已,最近一段时间,在工作中不断的接触到这方面的知识,才有了比较系统的思考与总结,本文记叙了我学习dx管线的一些思考,由于水平所限以及尚未对最新的DX11架构有所关注,所以本文不涵盖DX11的最新架构,只涉及DX9及以前的知识结构,虽然如此,但是相信如果系统的掌握了这块知识,对了解后续的新技术也是有所帮助的。
本文主要是针对对3D渲染知识有一定了解,然而对D3D的结构却不是很清楚的同学,把DX8/9的管线结构做了一番说明,将固定管线的光照、纹理混合与可编程管线加以对比,期望对这一块知识有一个比较完整的总结。
目录
1. 硬件加速的3D渲染
2. 材质与光照
3. 变换,光照与顶点着色器
4. 纹理与像素着色器
5. Alpha测试,深度测试与Alpha混合
6. 更多话题
1. 硬件加速的3D渲染
硬件加速的3D渲染,早些时候只是被称为Hardware Transform & Lighting的加速技术,这些技术是相对于早期时候,图形硬件尚未普及,软件实现的实时3d游戏中常用的软件变换与光照而言的。因此,最早的显卡最主要的目标也就是通过硬件来加速顶点变换,和顶点光照(如果严格来说,纹理采样应该也要算在内),从DX的固定管线接口中可以看出这一点,在固定管线中,通过SetTransform来设置变换矩阵,通过SetMaterial来设置材质属性,SetLight来设置光源参数,最后一个DrawPirmitive把三角形画到表面上去。
这几个步骤分别都是什么意思?接下来我会一一分说。
对于3D图形学有所了解的同学应当知道,绘制3D画面时,我们需要几样东西:
1) 所要绘制的目标:一个Mesh网格(包含了若干顶点,若干索引,实际构成为一个个三角形)
2) 物体的世界变换矩阵:通常我们表示一个3d物体(比如一个Mesh)的时候,都是用Local坐标系进行表示,实际渲染时,再乘上变换矩阵得到其在世界中的位置。这种做法有什么好处?或者是否非如此不可?读者可以想一想,其实原因非常简单。 : )
3) 摄像机与视口:我们如何去看待三维世界中的物体,摄像机定义了我们观察物体的位置,角度,以及远近变换的程度
上述三样东西是必须的,有了相机和绘制目标,以及绘制目标的世界矩阵,我们就可以进行3D绘制了。但是想要获得更加具有真实感的图像,我们需要给绘制目标加上光照(注意,光照不等同于阴影,尽管当我们要实施阴影的时候,也往往是按照光照时设置的光源来绘制阴影的)此处所说的光照是指顶点光照,根据光源与顶点的相对位置,顶点的法线朝向,以及物体的受光材质决定最终的顶点颜色。一个简单的光照函数类似下面这样:
DiffuseColor = Clamp(DotProduct(VertexNormal, VertexToLightDir), 0, 1) * LightColor * MaterialColor + Ambient
Clamp函数保证点积的结果在[0.0, 1.0]区间内
上面的这个函数没有考虑光源衰减,灯光范围,不考虑SpotLight的衰减,另外只涉及漫反射光照,高光则需另外计算。但是从原理上已经可以说明(顶点)光照是怎么一回事了。这里强调顶点光照,是相对于像素光照来说的,顶点光照与像素光照后文会详述。
公式中提到的VertexToLightDir, LightColor属于光源的属性,在D3D9中则是由D3DLIGHT9来描述的,通过D3DDevice::SetLight接口进行设置。而MaterialColor属于材质属性,描述结构为D3DMATERIAL9,通过SetMaterial设置。
这样我们得到了绘制3D物体的另两样参数,分别为:
4) 光源:决定灯光的强度,颜色,范围,衰减等
5) 材质:决定物体受光的颜色,比如是绿色的物体,或红色的物体等。
有了光源和材质,绘制的3D物体则有了明暗和颜色,这对于提升渲染的真实感是有很大帮助的。
在此做一下小节:我们刚才回顾了绘制3D画面时,所必要做的事情,我们想象一下,如果没有3D硬件,或者不利用D3D,要实现上述功能,得由我们自己去完成哪些事?
首先要做软件的顶点变换,对每一个顶点,使用世界矩阵把它乘到世界坐标系中,用相机参数做必要的可见性剔除,利用相机朝向与面法线朝向做隐藏面消除,用光源与材质参数对世界空间中的顶点位置做顶点光照,将世界空间中的三角形变换到屏幕空间中,利用光照得到的顶点颜色,对三角形做Gouraud插值着色,最终得到绘制在屏幕上的图像。(有一本书叫做《3D游戏编程大师技巧》,对上述过程作了非常详尽的描述)
好了,有了图形硬件与D3D之后,上述工作都省掉了,我们通过SetTransform来设置世界矩阵、观察矩阵和投影矩阵,通过SetMaterial/SetLight来设置材质和光源参数,然后通过DrawPrimitive来绘制三角面。图形硬件和D3D接口极大的简化了我们编写3D程序的难度,诸多繁杂的事情都被交给硬件去完成了。
2. 材质与光照
前一节主要是简单介绍了一下D3D硬件都为我们做了哪些事——事实上现如今的图形硬件所完成的功能已经远远不止上述那些了。接下来一节,我准备总结一下材质与光照的细节和原理。
上一节简单介绍了下材质与光照,但是没有深入系统的做总结,本节准备将这个过程做一个比较系统的总结。
关于几种基本光源,其公式,原理,实现,可以通过MiloYip大牛的这篇文章来了解。该文中的范例是基于光线追踪渲染器实现的,因此光和影的效果直接都有了,甚至在最后一个Demo中运用多光源模拟了软阴影的绘制。然而在光栅化渲染器中,光照与材质只能解决物体的颜色以及明暗问题,不能解决影子问题。要在光栅化渲染器中实现阴影的绘制,则是另外一番话题了。
接下来还是借助D3D的接口来说材质。
typedef struct _D3DMATERIAL9 {
D3DCOLORVALUE Diffuse; /* Diffuse color RGBA */
D3DCOLORVALUE Ambient; /* Ambient color RGB */
D3DCOLORVALUE Specular; /* Specular 'shininess' */
D3DCOLORVALUE Emissive; /* Emissive color RGB */
float Power; /* Sharpness if specular highlight */
} D3DMATERIAL9;
材质描述了物体是如何对光作出响应的。虽然在可编程管线中,材质、光照都有自定义的实现方法,然而在固定管线中,光照采用的都是D3D所固有的一套机制,D3D中的最终光照结果由下式构成:
Global Illumination = Ambient Light + Diffuse Light + Specular Light + Emissive Light
环境光(Ambient Light)的计算方法为:Ambient Lighting =
其中Ca为材质的Ambient分量,Ga为全局环境光颜色(通过ID3DDevice9::SetRenderState(D3DRS_AMBIENT, COLOR)设置全局环境光颜色),求和的部分是所有被激活的光源的环境光进行求和,光源的衰减,聚光灯的Factor(如果是聚光灯)会被考虑在内。
漫反射光(Diffuse Light)的计算方法:Diffuse Lighting =
解释一下这个方程,其中Cd是材质的DiffuseColor,Ld是光的DiffuseColor,N是顶点法线,Ldir是从顶点到光源的方向向量(N与Ldir都是单位向量),再乘以光源的衰减参数,及SpotLight的因子。这里的N.Ldir正是计算漫反射光强弱的关键,一个顶点是否能被光源照亮,取决于该点法线与光方向的夹角,如果两者重合,该点受光程度达到最大,如果夹角大于等于90度,则不受光。
高光(Specular Light)的计算方法:Specular Lighting =
Cs是材质的Specular值,p是材质中的Power,Ls是灯光的Specular值,N是顶点法线,H是一个被称为Halfway vector的单位方向,该向量的含义为顶点到摄像机的向量与顶点到光源的向量的中间向量,计算方式为(H = norm(norm(Cp – Vp) + Ldir))
关于Halfway vector的含义,可以参考下图:
仔细思考一下我们就能明白,相比将入射光向各个方向均匀反射的漫反射而言(对方向不敏感),高光对视线与反射光线方向是敏感的,也就是说,从眼睛到顶点与反射光方向的夹角越小就会越亮,日常生活中我们往往可以在光滑表面看到高光现象,比如汽车表面的喷漆或者玻璃。
最后一个是自发光,自发光很简单,就直接等于材质参数中的Emissive分量:Emissive Lighting = Ce
上述这些就是D3D固定管线中材质与光照的工作原理。其实所有的公式都可以在DXSDK的文档中获取到,这里不过是简单总结了一下。
前面所描述的这些材质与光照,依赖于正确的输入,比如顶点法线:漫反射和高光的计算都要依赖于顶点法线。我们都知道D3D有自定义的顶点格式,被称为FVF的东西。那么也就是说,如果想要正确的使用D3D顶点光照,我们采用的顶点格式,应当至少包含法线(D3DFVF_NORMAL)。
我们也常见到不包含法线,而是包含漫反射颜色(D3DFVF_DIFFUSE)和高光色彩(D3DFVF_SPECULAR)的顶点格式,采用这种顶点格式往往意味着我们准备自行为顶点填充颜色,而不采用D3D光照。
3. 变换,光照与顶点着色器
要说顶点着色器,则不得不从固定管线与顶点格式说起。D3D中有所谓的Flexible Vertex Format,也就是大名鼎鼎的FVF常量。我们常见的D3DFVF_DIFFUSE,D3DFVF_XYZ,D3DFVF_NORMAL都是定义好的常量,在需要用的时候,我们把这些常量“或”在一起,构成一个顶点格式,传递给D3D使用。D3D固定管线通过该FVF格式对我们传入的VertexBuffer进行解释(每份顶点数据的长度,以及每个成员的偏移)。
起初学习D3D的时候,我对FVF格式有个疑惑,百思而不得其解。我们都知道要写一个普通的具有位置,法线和UV的顶点格式声明应该怎么写:
#define MY_VERTEX_FVF D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_TEX0
struct VERTEX
{
float x, y, z;
float nx, ny, nz;
float u, v;
};
我觉得很奇怪,D3D怎么知道我把x,y,z放在前面还是把nx,ny,nz放在前面,难道是根据我定义FVF时的先后顺序?明显讲不通啊:因为或运算不可能给出任何先后顺序的信息来的。
直到后来我才明白,原来D3D固定管线的顶点格式也是有固定顺序的,也就是说如果要写一个既带有位置,又带有法线的顶点数据结构,那么x,y,z一定要放在nx,ny,nz之前,或者说,D3D是根据一个固定的偏移去解释这个顶点数据结构的,首先一定是位置,然后才是法线,其他更复杂包含更多成员的数据格式也是与此同理。什么意思呢?
还是以上面的例子来解释,你把顶点数据结构写成下面这样(区别在于,nx,ny,nz与x,y,z调换了顺序):
#define MY_VERTEX_FVF D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_TEX0
struct VERTEX
{
float nx, ny, nz;
float x, y, z;
float u, v;
};
D3D会说,不好意思,我不知道你怎么命名的,也不在乎你把它叫nx还是x,反正我都把你你头三个浮点数就当作是位置,接下来三个浮点数当成是法线,只要你给我的FVF是有D3DFVF_XYZ的,那头三个浮点就是位置没跑了!
从上面的例子中我们可以看到,实际上固定管线的结构是很死板的,缺乏给程序员自由发挥的空间,它规定好了一系列的顶点数据的用途,然后你必须按照它指定的方式去做,否则结果就不对。
当然了,这也只是后来的可编程管线替代固定管线的诸多原因之一。如今我们见到的市面上的所有显卡基本上已经没有不支持可编程管线的了,而且DX也已经更新换代到了DX11,新架构与本文中所介绍的基于DX9的结构又有了很多变化。
继续前面所述的话题,D3DFVF格式不仅决定了顶点数据结构如何定义,也说明了顶点要如何进行坐标系变换,比如:
D3DFVF_XYZ说明了顶点需要进行全套的世界、观察、投影变换;
D3DFVF_XYZRHW则说明该顶点坐标已经是屏幕坐标,x,y表示窗口中的坐标,z值表示z-buffer中的值,从近到远为0.0~1.0,采用这种格式的顶点声明不能与D3DFVF_XYZ或者D3DFVF_NORMAL混用,并且不经过顶点处理单元;
D3DFVF_XYZW也是表示已经变换过后的顶点,但是这种顶点格式会经过顶点处理单元,也就是vertexshader的阶段;
D3DFVF_XYZBn (n=1..4) 表示该顶点在变换阶段需要进行混合,啥意思?也就是说在做变换的时候,不是一个世界矩阵对其起作用,而是有n个矩阵,根据Blend的参数共同对该顶点起作用。如下例(摘自DXSDK):
#define D3DFVF_BLENDVERTEX (D3DFVF_XYZB3|D3DFVF_NORMAL|D3DFVF_TEX1)
struct BLENDVERTEX
{
D3DXVECTOR3 v; // Referenced as v0 in the vertex shader
FLOAT blend1; // Referenced as v1.x in the vertex shader
FLOAT blend2; // Referenced as v1.y in the vertex shader
FLOAT blend3; // Referenced as v1.z in the vertex shader
// v1.w = 1.0 - (v1.x + v1.y + v1.z)
D3DXVECTOR3 n; // Referenced as v3 in the vertex shader
FLOAT tu, tv; // Referenced as v7 in the vertex shader
};
也就是说v在被变换的时候,会被4个矩阵依次乘上,并将其结果使用(blend1,blend2,blend3,1-(blend1 + blend2 + blend3))四个参数作为系数进行混合。注意一下SetTransform函数,它有两个参数,第一个参数的说明里有这么一段:
State
[in] Device-state variable that is being modified. This parameter can be any member of the
D3DTRANSFORMSTATETYPE enumerated type, or the
D3DTS_WORLDMATRIX macro.
其中的D3DTS_WORLDMATRIX是一个宏,可以像这么用D3DTS_WORLDMATRIX(0),D3DTS_WORLDMATRIX(1),这样一来就可以传递多个矩阵到固定管线中了(等到可编程管线就没这么麻烦了,可以直接通过顶点着色器常量传递矩阵)。
最后简单说一下D3DFVF_XYZBn这种顶点格式的用途:这种顶点格式往往被用在骨骼动画的顶点里,因为骨骼动画需要一个顶点受多个骨骼的影响。
定义好了FVF与顶点数据结构之后,我们需要把它设置到设备上去。我们知道,早些年的DX8也好,DX9也好,其Device都有一个SetVertexShader接口,用于设置顶点着色器程序。而在DX8中,这个接口不仅仅是可编程管线的vertex shader通过它来设置,实际上如果采用的是固定管线的话,顶点的FVF格式也是走这个接口设置的。DX8中的Vertex shader创建出来之后是一个32位的DWORD类型的handle,从而得以与FVF常量共用这同一个接口。而在DX9中,SetVertexShader的接受参数变成一个IDirect3DVertexShader9*了,而固定管线的FVF被挪走,需要通过接口SetFVF来进行设置了。
另外,由于在DX9中,IDirect3DVertexShader9在创建的时候,并没有包含顶点声明信息,因此DX9增加了一个接口IDirect3DVertexDeclaration9专门用于为可编程管线的VertexShader提供顶点声明信息。为什么一定要有顶点声明信息呢?写过HLSL的同学都知道,我们在vertex shader里是必须要定义vs input的数据结构的,顶点声明信息必须与我们的VS_INPUT数据结构保持一致。那么这个顶点声明信息在什么时候用呢?仔细思考一下就会明白,vs是一段运行于GPU上的程序,它按照我们编写的HLSL代码接受输入,经过计算后输出,输入的数据是在顶点缓冲中准备好的,在DrawPrimitive之前,通过SetVertexBuffer设置,此后这些输入数据通过驱动程序被提供给显卡,然而底层如何知晓应把多长一截的数据作为一份输入的顶点数据呢?以及应该把顶点数据中的哪一部分送到顶点寄存器里,哪些送到纹理采样单元里去?到这里,就不得不依赖顶点声明提供的信息了。
前面在材质与光照一节,我们简单介绍了一下D3D固定管线光照中,材质是如何与灯光一起起作用,并作用于物体的色泽,明暗,高光的。现在当我们手握Shader这样的利器,就拥有了绕开D3D固定管线光照的自由了(譬如如今已经很普及的基于像素的光照技术,就是把光照从顶点着色阶段推迟至像素着色器中)。我们还可以利用顶点着色器的常量传递变换矩阵,从而拥有自定义顶点变换,或者在顶点变换阶段获取一些我们想要的中间结果的能力。
4. 纹理与像素着色器
前面说了关于顶点变换与顶点光照相关的东西。接下来要说的是纹理混合,也就是SetTextureStageState函数干的事情。我们都知道DX支持纹理混合,我们可以把多个纹理按照Sampler的索引(DX9中是Sampler,而在DX8中则是Stage),通过SetTexture函数设置到设备上。
所谓Stage是什么一个概念呢?参考下图(来源于DX9SDK):
在每一个Stage上设有一张纹理(如果没有设置纹理),在固定管线中,我们通过SetTextureStageState设置在当前的Stage中,纹理如何与“当前像素”混合并输出到下一个Stage,“当前像素”来源于上一个Stage的输出,0Stage的当前像素的Color及Alpha是顶点Color/Alpha插值得来的。在纹理阶段比较常见的就是DiffuseTexture,SpecularTexture,分别用于颜色与高光。在每一个Stage中,纹理颜色与上一阶段颜色采用下式进行混合:
FinalColor = TexelColor × SourceBlendFactor + PixelColor × DestBlendFactor
这里需要注意的是,纹理混合与最终的AlphaBlending是两个概念。纹理混合阶段,根据顶点颜色以及纹理决定最终要输出的像素的颜色和透明度,而到了AlphaBlending阶段,则是决定该像素与已经绘制到BackBuffer上的像素如何去混合。此外,更复杂的功能如NormalMapping,通常则需要像素着色器参与。按照我的理解,如果想要实现像素光照,则必须把光照阶段从顶点着色器中推迟到像素着色器中才可行,NormalMap提供了这一阶段每个像素的法线,从而使像素光照得以实现。(D3D9在固定管线中提供了一套BumpMap,但是老实说,D3D9文档里对此的说明看得我云里雾里的,并没有搞清楚其原理究竟是什么。)
像素着色器最初的就是为了替代固定管线的纹理混合操作而诞生的。也就是说,如果采用了PixelShader对像素进行着色,那么SetTextureStageState里面的那些颜色,Alpha混合操作就不再起作用了。
我们以一个常见的例子来解释纹理混合的用法,美术可能要求实现一种正片叠底的纹理混合效果,对于程序实现而言,实现方案就是找两张贴图,用乘法进行纹理混合。如果采用SetTextureStageState实现:
pD3DDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
pD3DDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
pD3DDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_MODULATE);
pD3DDevice->SetTextureStageState(1, D3DTSS_COLORARG1, D3DTA_TEXTURE);
pD3DDevice->SetTextureStageState(1, D3DTSS_COLORARG2, D3DTA_CURRENT);
解释一下这里的两个StageState的设置:阶段0,我们对Color采用SELECTARG1操作,同时把COLORARG1,设置为D3DTA_TEXTURE,也就是说,完全以SetTexture(0, …)设置的纹理颜色作为这一阶段的输出;在阶段1,COLOROP为MODULATE,也就是将COLORARG1与COLORARG2相乘作为输出,公式:
其中COLORARG1设置为D3DTA_TEXTURE,也就是SetTexture(1, …)中设置的纹理,而COLORARG2为D3DTA_CURRENT,表示的是上一阶段的输出。这样经过这两个Stage之后,我们获得的输出就是两张贴图相乘的结果了。
接下来再用Ps2.0(需要DX9,如果采用DX8则需用ps1.4实现)实现一遍该效果:
texture g_Tex0;
texture g_Tex1;
sampler g_Sampler0 = sampler_state {Texture = g_Tex0; MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR;}
sampler g_Sampler1 = sampler_state {Texture = g_Tex1; MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR;}
struct VS_OUTPUT
{
float4 Position : POSITION; // vertex position
float2 TexCoord : TEXCOORD0; // vertex texture coords
};
float4 PS_Main_2_0( VS_OUTPUT Input ) : COLOR0
{
float4 color0 = tex2D( g_Sampler0, Input.TexCoord );
float4 color1 = tex2D( g_Sampler1, Input.TexCoord );
return float4(color0 * color1);
}
使用ps的话,所需要做的工作非常直观,用预先定义好的Sampler以及uv坐标对纹理采样,并把颜色相乘输出即可。
这只是一个最简单的例子,实际上ps的用途非常广泛(实现ShaderMap,像素光照,诸多后期处理效果都需要采用ps实现),而更多的内容就不可能涵盖在本文的范围之内了,感兴趣的读者可以读一读GPUGems系列,ShaderX系列等经典书籍。
5. Alpha测试,深度测试,以及Alpha混合
走完了像素着色的过程之后,就到了最终的绘制步骤了。这里有几个因素决定我们是否,以及如何往BackBuffer/ZBuffer/StencilBuffer上绘制像素:AlphaTest,StencilTest,ZTest,Alpha混合模式。
关于上述测试的顺序和流程,由于OpenGl与D3D在上述测试的顺序上达成了一致,因此可以参考下图(图摘自OpenGL2.0 spec Page 199):
如果开启了AlphaTest,则首先做AlphaTest,根据像素的Alpha值与一个指定的D3DRS_ALPHAREF值比较的结果,将D3DRS_ALPHAFUNC的判断未能通过的像素直接砍掉。(不会进行后续的z-test以及z-write)
如果开启了StencilTest,则继续做StencilTest,如果不通过StencilTest,则也不会进行下一步ZTest,但是可能会修改StencilBuffer。
如果开启了ZTest,根据当前像素Z值,与DepthBuffer上该位置已有Z值做比较,如果D3DRS_ZFUNC的判断未能通过,则将该像素砍掉,但是根据D3DRS_STENCILZFAIL的状态,有可能会修改StencilBuffer。通过了ZBuffer的像素,如果开启了ZWrite,则该像素的Z值会写入DepthBuffer,否则(ZWrite关闭)该像素不会影响DepthBuffer(也就是说不会遮挡后续绘制的像素,尽管可能该像素离观察者比将来要绘制的像素更近)
最后根据D3DRS_ALPHABLENDENABLE决定是否开启Alpha混合。如果Alpha混合没有开启:则直接把像素的颜色透明度写入BackBuffer,如果Alpha混合开启了,则根据D3DRS_SRCBLEND以及D3DRS_DESTBLEND的选项,决定新写入像素如何与BackBuffer已有像素进行混合。
上述流程是标准流程,然而在较新的几代显卡里,针对StencilTest与ZTest又有了许多新的技术,比如EarlyZ优化等,由于这里牵扯到的显卡型号,各厂商可能都有不一致的地方,我就没有做更进一步的详细研究。下面有几个可以供参考的链接,感兴趣的同学可以自己了解一下:
amddeveloper的一篇文章在这里:http://developer.amd.com/media/gpu_assets/Depth_in-depth.pdf
beyond3d上的一个讨论帖看这里:http://forum.beyond3d.com/showthread.php?t=51025
6. 更多话题
本文介绍了D3D流水线的一般过程,对比了固定管线与可编程管线的差别(当然有很多细节未能兼顾到),对D3D渲染流程的知识结构做了一个一般性的总结。
事实上,随着图形技术的发展,如今的3D图形技术已远走出很长一段路了,比如在DX9架构下就已经发展出很多成果的ShadowMap,NormalMap,Realtime Global Illumination等。再比如到DX10新增的Geometry Shader以及DX11架构下新增的Compute Shader,Tessellation等。
有时候学得越多,才越知道自己所知甚少。在撰写本文的过程中,我也把此前有些混淆的概念做了整理,自己也有不少收获。如果读者对本文所涉的话题有更多理解,或者发现了错漏之处,还望不吝告知。 : )