本系列文章由@浅墨_毛星云出品,转载请注明出处。
文章链接:/poem_qianmo/article/details/49719247
作者:毛星云(浅墨)微博:/u/1723155442
本文工程使用的Unity3D版本:5.2.1
概要:本文讲解了Unity中着色器编译多样化的思路,并对Standard Shader中正向基础渲染通道的源码进行了分析,以及对屏幕油画特效进行了实现。
众所周知,Unity官方文档对Shader进阶内容的讲解是非常匮乏的。本文中对Stardard Shader源码的一些分析,全是浅墨自己通过对Shader源码的理解,以及Google之后理解与分析而来。如有解释不妥当之处,还请各位及时指出。
依然是附上一组本文配套工程的运行截图之后,便开始我们的正文。本次的选用了新的场景,正如下图中所展示的。
城镇入口(with 屏幕油画特效):
城镇入口(原始图):
图依然是贴这两张。文章末尾有更多的运行截图,并提供了源工程的下载。先放出可运行的exe下载,如下:
【可运行的本文配套exe游戏场景请点击这里下载】
提示:在此游戏场景中按F键可以开关屏幕特效。
着色器编译多样化算是Unity5中Shder书写的新特性,标准着色器之所以能独当一面,正是得益于这种特性,在这里先对此特性进行一个简单的说明与讲解。
一、关于着色器编译多样化
此部分参考自Unity5.2.1版官方文档(/Manual/SL-MultipleProgramVariants.html),经翻译&理解后而成。如有解释不妥当之处,还请各位及时指出。
Unity5中使用了一种被称为着色器编译多样化(Multiple shader program variants)的新技术,常被称为“megashaders”或“uber shaders”,并通过为每种情况提供不同的预处理指令来让着色器代码多次被编译来实现。
在Unity中,这可以通过#pragmamulti_compile或者#pragma shader_feature指令来在着色器代码段中实现。这种做法对表面着色器也可行。
在运行时,相应的着色器变体是从材质的关键词中取得的(Material.EnableKeyword和 DisableKeyword),或者全局着色器关键词(Shader.EnableKeyword和 DisableKeyword)。
1.1 multi_compile的用法简析
若我们定义如下指令:
[cpp]view plaincopy print? #pragmamulti_compileFANCY_STUFF_OFFFANCY_STUFF_ON
也就表示定义了两个变体:FANCY_STUFF_OFF和FANCY_STUFF_ON。在运行时,其中的一个将被激活,根据材质或者全局着色器关键词(#ifdef FANCY_STUFF_OFF之类的宏命令也可以)来确定激活哪个。若两个关键词都没有启用,那么将默认使用前一个选项,也就是关闭(OFF)的选项FANCY_STUFF_OFF。
需要注意,也可以存在超过两个关键字的multi_compile编译选项,比如,如下代码将产生4种着色器的变体:
[cpp]view plaincopy print? #pragmamulti_compileSIMPLE_SHADINGBETTER_SHADINGGOOD_SHADINGBEST_SHADING
当#pragma multi_compile中存在所有名字都是下划线的一个指定段时,就表示需在没有预处理宏的情况下产生一个空的着色器变种。这种做法在着色器编写中比较常见,因为这样可以在不影响使用的情况下,避免使用两个关键词,这样就节省了一个变量个数的占用(下面会提到,Unity中关键词个数是有129个的数量限制的)。例如,下面的指令将产生两个着色器变体;第一个没有定义,第二个定义为FOO_ON:
[cpp]view plaincopy print? #pragmamulti_compile__FOO_ON
这样就省去了一个本来需要定义出来的 FOO_OFF(FOO_OFF没有定义,自然也不能使用),节省了一个关键词个数的占用。
若Shader中有如上定义,则可以使用#ifdef来进行判断:
[cpp]view plaincopy print? #ifdefFOO_ON //代码段1 #endif
根据上面已经定义过的FOO_ON,此#ifdef判断的结果为真,代码段1部分的代码就会被执行到。反之,若#pragma multi_compile __FOO_ON一句代码没有交代出来,那么代码段1部分的代码就不会被执行。
这就是着色器编译多样化的实现方式,其实理解起来很容易,对吧。
1.2 shader_feature和multi_compile之间的区别
#pragma shader_feature 和#pragma multi_compile非常相似,唯一的区别在于采用了#pragmashader_feature语义的shader,在遇到不被使用的变体的时候,就不会将其编译到游戏中。所以,shader_feature中使得所有的设置到材质中的关键词都是有效的,而multi_compile指令将从全局代码里设置关键词。
另外,shader_feature还有一个仅仅含有一个关键字的快捷表达方式,例如:
[cpp]view plaincopy print? #pragmashader_featureFANCY_STUFF
此为#pragma shader_feature _ FANCY_STUFF的一个简写形式,其扩展出了两个着色器变体,第一种变体自然为不定此FANCY_STUFF变量(那么若在稍后的Shader代码中进行#ifdef FANCY_STUFF的判断,则结果为假),第二种变体为定义此FANCY_STUFF变量(此情况下#ifdef FANCY_STUFF的判断结果为真)。
1.3 多个multi_compile连用会造成指数型增长
可以提供多个multi_compile流水线,然后着色器的结果可以被编译为几个流水线的排列组合,比如:
[cpp]view plaincopy print? #pragmamulti_compileABC #pragmamulti_compileDE
第一行中有3种选项,第二行中有两种选项,那么进行排列组合,总共就会有六种选项(A+D, B+D, C+D, A+E, B+E, C+E)。
容易想到,一般每以个multi_compile流水线,都控制着着色器中某一单一的特性。请注意,着色器总量的增长速度是非常快的。
比如,10条包含两个特性的multi_compil指令,会得到2的10次方,也就是1024种不同的着色器变体。
1.4 关于Unity中的关键词限制Keyword limit
当使用着色变量时,我们应该记住,Unity中将关键词的数量限制在了128个之内(着色变量算作关键字),且其中有一些已经被Unity内置使用了,因此,我们真正可以自定义使用关键词的数量以及是小于128个的。同时,关键词是在单个Unity项目中全局使用并计数的,所以我们要千万小心,在同一项目中存在的但没用到Shader也要考虑在内,千万不要合起来在数量上超出Unity的关键词数量限制了。
1.5 Unity内置的快捷multi_compile指令
如下有Unity内置的几个着色器变体的快捷多编译指令,他们大多是应对Unity中不同的光线,阴影和光照贴图类型。详情见rendering pipeline。
multi_compile_fwdbase- 此指令表示,编译正向基础渲染通道(用于正向渲染中,应用环境光照、主方向光照和顶点/球面调和光照(Spherical Harmonic Lighting))所需的所有变体。这些变体用于处理不同的光照贴图类型、主要方向光源的阴影选项的开关与否。multi_compile_fwdadd- 此指令表示, 编译正向附加渲染通道(用于正向渲染中;以每个光照一个通道的方式应用附加的逐像素光照)所需的所有变体。这些变体用于处理光源的类型(方向光源、聚光灯或者点光源),且这些变种都包含纹理cookie。multi_compile_fwdadd_fullshadows– 此指令和上面的正向渲染附加通道基本一致,但同时为上述通道的处理赋予了光照实时阴影的能力。multi_compile_fog- 此指令表示,编译出几个不同的Shader变体来处理不同类型的雾效(关闭/线性/指数/二阶指数)(off/linear/exp/exp2).
1.6 使用指令跳过某些变体的编译
大多数内置的快捷指令导致了很多着色的变体。若我们熟悉他们且知道有些并非所需,可以使用#pragmaskip_variants语句跳过其中一些的编译。例如:
[cpp]view plaincopy print? #pragmamulti_compile_fwdadd //将跳过所有使用"POINT"或"POINT_COOKIE"的变体 #pragmaskip_variantsPOINTPOINT_COOKIE
OK,通过上面经过翻译&理解过后的官方文档材料,应该对Unity中的着色器编译多样化有了一个理解。说白了,着色器变体的定义和使用与宏定义很类似。
1.7 对知识的提炼
上面交代了这么多,看不懂没关系,我们提炼一下,看懂这段提炼,关于着色器变体的意义与使用方式,也就懂了大半了。
若我们在着色器中定义了这一句:
[cpp]view plaincopy print? #pragmashader_feature_THIS_IS_A_SAMPLE
这句代码理解起来,也就是_THIS_IS_A_SAMPLE被我们定义过了,它是存在的,以后我们如果判断#ifdef _THIS_IS_A_SAMPLE,那就是真了。我们可以在这个判断的#ifdef…… #endif块里面实现自己需要的实现代码X,这段实现代码X,只会在你用#pragma multi_compile 或#pragmashader_feature定义了_THIS_IS_A_SAMPLE这个“宏”的时候会被执行,否则,它就不会被执行到。
实现代码X的执行与不执行,全靠你对变体的定义与否。这就是着色器编译多样化的实现方式,一个着色器+多个CG头文件的小团队(如标准着色器),可以独当一面,一个打一群,可以取代一大堆独立实现的Shader的原因所在。
二、Standard Shader中正向基础渲染通道源码分析
这一节主要用来解析Standard Shader中正向基础渲染通道的源码。
先上Standard Shader正向渲染基础通道(Shader Model 3.0版)的Shader源代码:
[cpp]view plaincopy print? //------------------------------------【子着色器1】------------------------------------ //此子着色器用于ShaderModel3.0 //---------------------------------------------------------------------------------------- SubShader { //渲染类型设置:不透明 Tags{"RenderType"="Opaque""PerformanceChecks"="False"} //细节层次设为:300 LOD300 //--------------------------------通道1------------------------------- //正向基础渲染通道(Baseforwardpass) //处理方向光,自发光,光照贴图等... Pass { //设置通道名称 Name"FORWARD" //于通道标签中设置光照模型为ForwardBase,正向渲染基础通道 Tags{"LightMode"="ForwardBase"} //混合操作:源混合乘以目标混合 Blend[_SrcBlend][_DstBlend] //根据_ZWrite参数,设置深度写入模式开关与否 ZWrite[_ZWrite] //===========开启CG着色器语言编写模块=========== CGPROGRAM //着色器编译目标:Model3.0 #pragmatarget3.0 //编译指令:不使用GLES渲染器编译 #pragmaexclude_renderersgles //---------编译指令:着色器编译多样化-------- #pragmashader_feature_NORMALMAP #pragmashader_feature__ALPHATEST_ON_ALPHABLEND_ON_ALPHAPREMULTIPLY_ON #pragmashader_feature_EMISSION #pragmashader_feature_METALLICGLOSSMAP #pragmashader_feature____DETAIL_MULX2 #pragmashader_feature_PARALLAXMAP //--------着色器编译多样化快捷指令------------ //编译指令:编译正向渲染基础通道(用于正向渲染中,应用环境光照、主方向光照和顶点/球面调和光照)所需的所有变体。 //这些变体用于处理不同的光照贴图类型、主要方向光源的阴影选项的开关与否 #pragmamulti_compile_fwdbase //编译指令:编译几个不同变种来处理不同类型的雾效(关闭/线性/指数/二阶指数/) #pragmamulti_compile_fog //编译指令:告知编译器顶点和片段着色函数的名称 #pragmavertexvertForwardBase #pragmafragmentfragForwardBase //包含辅助CG头文件 #include"UnityStandardCore.cginc" //===========结束CG着色器语言编写模块=========== ENDCG } …… }
OK,一起来稍微分析一下上述代码。基本上是逐行注释,所以找几个容易疑惑的点来提一下。
第一处,着色器编译多样化部分,代码如下:
[cpp]view plaincopy print? //---------编译指令:着色器编译多样化-------- #pragmashader_feature_NORMALMAP #pragmashader_feature__ALPHATEST_ON_ALPHABLEND_ON_ALPHAPREMULTIPLY_ON #pragmashader_feature_EMISSION #pragmashader_feature_METALLICGLOSSMAP #pragmashader_feature____DETAIL_MULX2 #pragmashader_feature_PARALLAXMAP
上文刚讲过着色器编译多样化的一些理解,理解起来就是这样,这边定义了很多的“宏”、 _NORMALMAP、_ALPHATEST_ON、_ALPHABLEND_ON、_EMISSION、_METALLICGLOSSMAP、_DETAIL_MULX2、_PARALLAXMAP,在顶点和片段着色器实现部分,可以用#ifdef _EMISSION类似的宏命令来对不同情况下的实现进行区别对待。
第二处,着色器编译多样化快捷指令部分,上文的讲解部分也有分别提到,这里代码注释已经很详细,如下:
[cpp]view plaincopy print? //--------着色器编译多样化快捷指令------------ //编译指令:编译正向渲染基础通道(用于正向渲染中,应用环境光照、主方向光照和顶点/球面调和光照)所需的所有变体。 //这些变体用于处理不同的光照贴图类型、主要方向光源的阴影选项的开关与否 #pragmamulti_compile_fwdbase //编译指令:编译几个不同变种来处理不同类型的雾效(关闭/线性/指数/二阶指数/) #pragmamulti_compile_fog
第三处,顶点着色函数和片段着色函数声明部分,代码如下:
[cpp]view plaincopy print? //编译指令:告知编译器顶点和片段着色函数的名称 #pragmavertexvertForwardBase #pragmafragmentfragForwardBase
这里比较关键,指明了这个pass中顶点着色函数和片段着色函数分别是名为vertForwardBase和fragForwardBase的函数。而这两个函数定义于何处?看包含头文件是什么即可。一起来看一下第四处。
第四处,CG头文件包含部分,代码如下:
[cpp]view plaincopy print? //包含辅助CG头文件 #include"UnityStandardCore.cginc"
很简单的一句话,但却像一切编程语言中头文件的包含一样,非常关键,不能缺少。vertForwardBase和 fragForwardBase的函数全都定义于此“UnityStandardCore.cginc”头文件中。
OK,我们转到“UnityStandardCore.cginc”头文件,继续分析下去。先从vertForwardBase函数开始。
1.顶点着色函数——vertForwardBase
vertForwardBase函数也已详细注释好,代码如下:
[cpp]view plaincopy print? //-----------------------------------【vertForwardBase函数】---------------------------------------- //用途:正向渲染基础通道的顶点着色函数 //说明:实例化一个VertexOutputForwardBase结构体对象,并进行相应的填充 //输入:VertexInput结构体 //输出:VertexOutputForwardBase结构体 //附:VertexInput结构体原型: /* structVertexInput {float4vertex:POSITION; half3normal:NORMAL; float2uv0:TEXCOORD0; float2uv1:TEXCOORD1; #ifdefined(DYNAMICLIGHTMAP_ON)||defined(UNITY_PASS_META) float2uv2:TEXCOORD2; #endif #ifdef_TANGENT_TO_WORLD half4tangent:TANGENT; #endif }; */ //--------------------------------------------------------------------------------------------------------- VertexOutputForwardBasevertForwardBase(VertexInputv) { //【1】实例化一个VertexOutputForwardBase结构体对象 VertexOutputForwardBaseo; //用Unity内置的宏初始化参数 UNITY_INITIALIZE_OUTPUT(VertexOutputForwardBase,o); //【2】通过物体坐标系到世界坐标系的变换矩阵乘以物体的顶点位置,得到对象在世界坐标系中的位置 float4posWorld=mul(_Object2World,v.vertex); //【3】若定义了镜面立方体投影宏,将计算得到的世界坐标系的xyz坐标作为输出参数的世界坐标值 #ifUNITY_SPECCUBE_BOX_PROJECTION o.posWorld=posWorld.xyz; #endif //【4】输出的顶点位置(像素位置)为模型视图投影矩阵乘以顶点位置,也就是将三维空间中的坐标投影到了二维窗口 o.pos=mul(UNITY_MATRIX_MVP,v.vertex); //【5】计算纹理坐标,使用UnityStandardInput.cginc头文件中的辅助函数。 o.tex=TexCoords(v); //【6】视线的方向=对象在世界坐标系中的位置减去摄像机的世界空间位置,并进行逐顶点归一化 o.eyeVec=NormalizePerVertexNormal(posWorld.xyz-_WorldSpaceCameraPos); //【7】计算物体在世界空间中的法线坐标 float3normalWorld=UnityObjectToWorldNormal(v.normal); //【8】进行世界空间中的切线相关参数的计算与赋值 //若定义了_TANGENT_TO_WORLD #ifdef_TANGENT_TO_WORLD //世界空间中的物体的法线值 float4tangentWorld=float4(UnityObjectToWorldDir(v.tangent.xyz),v.tangent.w); //在世界空间中为每个顶点创建切线 float3x3tangentToWorld=CreateTangentToWorldPerVertex(normalWorld,tangentWorld.xyz,tangentWorld.w); //分别为3个分量赋值 o.tangentToWorldAndParallax[0].xyz=tangentToWorld[0]; o.tangentToWorldAndParallax[1].xyz=tangentToWorld[1]; o.tangentToWorldAndParallax[2].xyz=tangentToWorld[2]; //否则,三个分量直接取为0,0和上面计算得到的normalWorld #else o.tangentToWorldAndParallax[0].xyz=0; o.tangentToWorldAndParallax[1].xyz=0; o.tangentToWorldAndParallax[2].xyz=normalWorld; #endif //【9】阴影的获取 TRANSFER_SHADOW(o); //【10】进行顶点正向相关的全局光照操作 o.ambientOrLightmapUV=VertexGIForward(v,posWorld,normalWorld); //【11】若定义了_PARALLAXMAP宏,则计算视差的视角方向并赋值 #ifdef_PARALLAXMAP //声明一个由切线空间的基组成的3x3矩阵“rotation” TANGENT_SPACE_ROTATION; //计算视差的视角方向 half3viewDirForParallax=mul(rotation,ObjSpaceViewDir(v.vertex)); //分别将三个分量赋值给VertexOutputForwardBase结构体对象o的tangentToWorldAndParallax的三个分量 o.tangentToWorldAndParallax[0].w=viewDirForParallax.x; o.tangentToWorldAndParallax[1].w=viewDirForParallax.y; o.tangentToWorldAndParallax[2].w=viewDirForParallax.z; #endif //【12】若定义了UNITY_OPTIMIZE_TEXCUBELOD,便计算反射光方向向量并赋值 #ifUNITY_OPTIMIZE_TEXCUBELOD //使用CG语言内置函数reflect计算反射光方向向量 o.reflUVW=reflect(o.eyeVec,normalWorld); #endif //【13】从顶点中输出雾数据 UNITY_TRANSFER_FOG(o,o.pos); //【14】返回已经附好值的VertexOutputForwardBase类型的对象 returno; }
基本步骤已经在代码注释中用序号列出,以下将对其中的主要知识点进行讲解。首先看一下函数的输出参数——VertexInput。
2.顶点输入结构体——VertexInput
此结构体定义于UnityStandardInput.cginc头文件中,是顶点着色函数vertForwardBase的输入参数,相关代码如下所示:
[cpp]view plaincopy print? //顶点输入结构体 structVertexInput { float4vertex:POSITION;//位置坐标 half3normal:NORMAL;//法线向量 float2uv0:TEXCOORD0;//一级纹理坐标 float2uv1:TEXCOORD1;//二级纹理坐标 //若DYNAMICLIGHTMAP_ON或者UNITY_PASS_META选项为开,则还定义一个三级纹理 #ifdefined(DYNAMICLIGHTMAP_ON)||defined(UNITY_PASS_META) float2uv2:TEXCOORD2;//三级纹理 #endif #ifdef_TANGENT_TO_WORLD half4tangent:TANGENT;//切线向量 #endif };
此结构体比较通用,不仅仅是用于正向基础渲染通道,毕竟是定义在UnityStandardInput.cginc头文件中的。
各个变量的含义,注释中已经写到了,好像没有什么值得多说的,再来看下顶点输出结构体。
3.顶点输出结构体——VertexOutputForwardBase
顾名思义,VertexOutputForwardBase结构体就是正向基础渲染通道特有的输出结构体,定义于UnityStandardCore.cginc头文件中,注释后的代码如下:
[cpp]view plaincopy print? //正向渲染基础通道的输出结构体 structVertexOutputForwardBase { float4pos:SV_POSITION;//像素坐标 float4tex:TEXCOORD0;//一级纹理 half3eyeVec:TEXCOORD1;//二级纹理(视线向量) half4tangentToWorldAndParallax[3]:TEXCOORD2;//3x3为切线到世界矩阵的值,1x3为视差方向的值 half4ambientOrLightmapUV:TEXCOORD5;//球谐函数(Sphericalharmonics)或光照贴图的UV坐标 SHADOW_COORDS(6)//阴影坐标 UNITY_FOG_COORDS(7)//雾效坐标 //若定义了镜面立方体投影宏,定义一个posWorld #ifUNITY_SPECCUBE_BOX_PROJECTION float3posWorld:TEXCOORD8; #endif //若定义了优化纹理的立方体LOD宏,还将定义如下的参数reflUVW #ifUNITY_OPTIMIZE_TEXCUBELOD #ifUNITY_SPECCUBE_BOX_PROJECTION half3reflUVW:TEXCOORD9; #else half3reflUVW:TEXCOORD8; #endif #endif };
从这里开始,做一个规定,为了方便对照和理解,以下贴出代码中也会贴出原始的英文注释——先翻译为中文,以 || 结束,在 || 后附上原始的英文。
就像这样:
//最终的二次多项式 || Final quadraticpolynomial
OK,我们继续,vertForwardBase函数中有很多知识点值得拿出来讲一讲的。
4. UNITY_INITIALIZE_OUTPUT宏
UNITY_INITIALIZE_OUTPUT(type,name)–此宏用于将给定类型的名称变量初始化为零。在使用旧版标准所写的Shader时,经常会报错“Try adding UNITY_INITIALIZE_OUTPUT(Input,o); like this in your vertfunction.”之类的错误,加上这句就不会报错了。
5._Object2World矩阵
_Object2World,Unity的内置矩阵,世界坐标系到对象坐标系的变换矩阵,简称“世界-对象矩阵”。
6.UNITY_MATRIX_MVP矩阵
UNITY_MATRIX_MVP为当前的模型矩阵x视图矩阵x投影矩阵,简称“模型-视图-投影矩阵”。其常用于在顶点着色函数中,通过将它和顶点位置相乘,从而可以把顶点位置从模型空间转换到裁剪空间(clip space)中。也就是通过此矩阵,将三维空间中的坐标投影到了二维窗口中。
7.TexCoords函数
TexCoords函数用于获取纹理坐标,定义UnityStandardInput.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? float4TexCoords(VertexInputv) { float4texcoord; texcoord.xy=TRANSFORM_TEX(v.uv0,_MainTex);//Alwayssourcefromuv0 texcoord.zw=TRANSFORM_TEX(((_UVSec==0)?v.uv0:v.uv1),_DetailAlbedoMap); returntexcoord; }
函数实现代码中的_MainTex、_UVSec、_DetailAlbedoMap都是此头文件定义的全局的变量。
其中还涉及到了一个TRANSFORM_TEX宏,在这边也提一下,它定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //按比例和偏移进行二维UV坐标的变换 #defineTRANSFORM_TEX(tex,name)(tex.xy*name##_ST.xy+name##_ST.zw)
8. NormalizePerVertexNormal函数
此函数位于unitystandardcore.cginc头文件中,原型和注释如下:
[cpp]view plaincopy print? //--------------------------【函数NormalizePerVertexNormal】----------------------------- //用途:归一化每顶点法线 //说明:若满足特定条件,便归一化每顶点法线并返回,否则,直接返回原始值 //输入:half3类型的法线坐标 //输出:若满足判断条件,返回half3类型的、经过归一化后的法线坐标,否则返回输入的值 //----------------------------------------------------------------------------------------------- half3NormalizePerVertexNormal(half3n) { //满足着色目标模型的版本小于ShaderModel3.0,或者定义了UNITY_STANDARD_SIMPLE宏,返回归一化后的值 #if(SHADER_TARGET<30)||UNITY_STANDARD_SIMPLE returnnormalize(n); //否则,直接返回输入的参数,后续应该会进行逐像素的归一化 #else returnn; #endif }
其中,SHADER_TARGET宏代表的值为和着色器的目标编译模型(shader model)相关的一个数值。
例如,当着色器编译成Shader Model 3.0时,SHADER_TARGET 便为30。我们可以在shader代码中由此来进行条件判断。相关代码如下:
[cpp]view plaincopy print? #ifSHADER_TARGET<30 //实现代码A #else //实现代码B #endif
9. UnityObjectToWorldNormal函数
UnityObjectToWorldNormal是Unity内置的函数,可以将法线从模型空间变换到世界空间中,定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //将法线从模型空间变换到世界空间 inlinefloat3UnityObjectToWorldNormal(infloat3norm) { //将分量分别相乘,并进行归一化 //Multiplybytransposedinversematrix,actuallyusingtranspose()generatesbadlyoptimizedcode returnnormalize(_World2Object[0].xyz*norm.x+_World2Object[1].xyz*norm.y+_World2Object[2].xyz*norm.z); }
而其中的normalize( )函数太常见不过了,是来自CG语言中的函数,作用是归一化向量。
10.UnityObjectToWorldDir函数
UnityObjectToWorldDir函数用于方向值从物体空间切换到世界空间,也定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //将方向值从物体空间切换到世界空间 inlinefloat3UnityObjectToWorldDir(infloat3dir) { returnnormalize(mul((float3x3)_Object2World,dir)); }
可以看到,就是返回一个世界-对象矩阵乘以方向值归一化后的结果,比较好理解。
11. CreateTangentToWorldPerVertex函数
CreateTangentToWorldPerVertex函数用于在世界空间中为每个顶点创建切线,定义于UnityStandardUtils.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? half3x3CreateTangentToWorldPerVertex(half3normal,half3tangent,halftangentSign) { //对于奇数负比例变换,我们需要将符号反向||<spanstyle="font-family:Arial,Helvetica,sans-serif;">Forodd-negativescaletransformsweneedtoflipthesign</span> halfsign=tangentSign*unity_WorldTransformParams.w; half3binormal=cross(normal,tangent)*sign; returnhalf3x3(tangent,binormal,normal); }
其中的unity_WorldTransformParams是UnityShaderVariables.cginc头文件中定义的一个uniform float4型的变量,其w分量用于标定奇数负比例变换(odd-negativescale transforms),通常取值为1.0或者-1.0。
12.TRANSFER_SHADOW(a)宏
此宏用于进行阴影在各种空间中的转换,定义于AutoLight.cginc中。在不同的情况下,此宏代表的意义并不相同。下面简单进行下展开分析。
1)对于屏幕空间中的阴影(Screen space shadows)
对应于屏幕空间中的阴影,也就是#if defined (SHADOWS_SCREEN),其相关代码如下:
[cpp]view plaincopy print? #ifdefined(SHADOWS_SCREEN) …… #ifdefined(UNITY_NO_SCREENSPACE_SHADOWS) #defineTRANSFER_SHADOW(a)a._ShadowCoord=mul(unity_World2Shadow[0],mul(_Object2World,v.vertex)); #else//notUNITY_NO_SCREENSPACE_SHADOWS #defineTRANSFER_SHADOW(a)a._ShadowCoord=ComputeScreenPos(a.pos); …… #endif
也就是说,这种情况下的TRANSFER_SHADOW(a)宏,代表了一句代码,这句代码就是a._ShadowCoord = mul (unity_World2Shadow[0],mul(_Object2World,v.vertex));
此句代码的含义是:将世界-阴影坐标乘以世界-模型坐标和物体顶点坐标的积,也就是先将物体坐标转换成世界坐标,再将世界坐标转换成阴影坐标,并将结果存放于a._ShadowCoord中。
2)对于聚光灯阴影(Spot light shadows)
而对于聚光灯的阴影,也就是#if defined (SHADOWS_DEPTH)&& defined (SPOT)
有如下定义:
[cpp]view plaincopy print? #ifdefined(SHADOWS_DEPTH)&&defined(SPOT) #defineTRANSFER_SHADOW(a)a._ShadowCoord=mul(unity_World2Shadow[0],mul(_Object2World,v.vertex)); …… #endif
可以发现,这种情况下的TRANSFER_SHADOW(a)宏代表的语句也是a._ShadowCoord = mul (unity_World2Shadow[0],mul(_Object2World,v.vertex));
同上,用途就是先将物体坐标转换成世界坐标,再将世界坐标转换成阴影坐标,并将结果存放于a._ShadowCoord中。
3)对于点光源阴影(Point light shadows)
而对于点光源的阴影,也就是#if defined (SHADOWS_CUBE),有如下定义:
[cpp]view plaincopy print? #ifdefined(SHADOWS_CUBE) #defineTRANSFER_SHADOW(a)a._ShadowCoord=mul(_Object2World,v.vertex).xyz-_LightPositionRange.xyz; …… #endif
也就是说,这种情况下的TRANSFER_SHADOW(a)宏代表语句a._ShadowCoord = mul(_Object2World, v.vertex).xyz -_LightPositionRange.xyz;
想了解此代码的含义,先要知道_LightPositionRange变量的含义。
这个变量是UnityShaderVariables.cginc头文件中定义的一个全局变量:
[cpp]view plaincopy print? uniformfloat4_LightPositionRange;//xyz=pos,w=1/range
从英文注释可以发现,此参数的x,y,z分量表示世界空间下光源的坐标,而w为世界空间下范围的倒数。
那么此句代码的含义,也就是先将物体-世界矩阵乘以物体顶点坐标,得到物体的世界空间坐标,然后取坐标的xyz分量,与光源的坐标相减,并将结果赋给a._ShadowCoord。
4)对于关闭阴影(Shadows off)的情况
而对于关闭阴影的情况,也就是#if !defined (SHADOWS_SCREEN)&& !defined (SHADOWS_DEPTH) && !defined (SHADOWS_CUBE),有如下定义:
[cpp]view plaincopy print? #if!defined(SHADOWS_SCREEN)&&!defined(SHADOWS_DEPTH)&&!defined(SHADOWS_CUBE) #defineTRANSFER_SHADOW(a) …… #endif
这种情况下的TRANSFER_SHADOW(a)宏代表的是空白,并没有什么用。
13. VertexGIForward函数
定义于UnityStandardCore.cginc头文件中。详细注释后的代码如下:
[cpp]view plaincopy print? //顶点正向全局光照函数 inlinehalf4VertexGIForward(VertexInputv,float3posWorld,half3normalWorld) { //【1】定义一个half4型的ambientOrLightmapUV变量,并将四个分量都置为0 half4ambientOrLightmapUV=0; //【2】对ambientOrLightmapUV变量的四个分量赋值 //【2-1】若没有定义LIGHTMAP_OFF(关闭光照贴图)宏,也就是此情况下启用静态的光照贴图,则计算对应的光照贴图坐标 //staticlightmap #ifndefLIGHTMAP_OFF ambientOrLightmapUV.xy=v.uv1.xy*unity_LightmapST.xy+unity_LightmapST.zw; ambientOrLightmapUV.zw=0; //【2-2】若定义了UNITY_SHOULD_SAMPLE_SH宏,则表示对动态的对象采样(不对静态或者动态的光照贴图采样) //||SamplelightprobeforDynamicobjectsonly(nostaticordynamiclightmaps) #elifUNITY_SHOULD_SAMPLE_SH //【2-2-1】若定义了如下的UNITY_SAMPLE_FULL_SH_PER_PIXEL宏(即采样计算全部的每像素球面调和光照),便给ambientOrLightmapUV.rgb赋值为0 #ifUNITY_SAMPLE_FULL_SH_PER_PIXEL ambientOrLightmapUV.rgb=0; //【2-2-2】若满足着色目标模型的版本小于ShaderModel3.0,或者定义了UNITY_STANDARD_SIMPLE宏 //便使用球面调和函数ShadeSH9给ambientOrLightmapUV.rgb赋值 #elif(SHADER_TARGET<30)||UNITY_STANDARD_SIMPLE ambientOrLightmapUV.rgb=ShadeSH9(half4(normalWorld,1.0)); //【2-2-3】否则,使用三序球面调和函数ShadeSH3Order给ambientOrLightmapUV.rgb赋值 #else //优化操作:光源L0、L1逐像素,光源L2逐顶点||Optimization:L2per-vertex,L0..L1per-pixel ambientOrLightmapUV.rgb=ShadeSH3Order(half4(normalWorld,1.0)); #endif //【2-2-4】从非重要的点光源中添加近似的照明||Addapproximatedilluminationfromnon-importantpointlights //若定义了如下的VERTEXLIGHT_ON宏(即开启顶点光照),便使用Shade4PointLights函数给ambientOrLightmapUV.rgb赋值,添加环境光 #ifdefVERTEXLIGHT_ON //Shade4PointLights为Unity内置的逐顶点光照处理函数,定义于unityCG.cginc头文件中 ambientOrLightmapUV.rgb+=Shade4PointLights( unity_4LightPosX0,unity_4LightPosY0,unity_4LightPosZ0, unity_LightColor[0].rgb,unity_LightColor[1].rgb,unity_LightColor[2].rgb,unity_LightColor[3].rgb, unity_4LightAtten0,posWorld,normalWorld); #endif #endif //【2-3】若定义了如下的VERTEXLIGHT_ONDYNAMICLIGHTMAP_ON宏(即开启动态光照贴图),则给变量的zw分量赋值 #ifdefDYNAMICLIGHTMAP_ON ambientOrLightmapUV.zw=v.uv2.xy*unity_DynamicLightmapST.xy+unity_DynamicLightmapST.zw; #endif //【3】返回ambientOrLightmapUV变量的值 returnambientOrLightmapUV; }
其中有一些小的点,这边提出来讲一下。
1)unity_LightmapST变量
unity_LightmapST变量类型为float4型,定义于UnityShaderVariables.cginc头文件中,存放着光照贴图操作的参数的值:
[cpp]view plaincopy print? float4unity_LightmapST;
2)UNITY_SHOULD_SAMPLE_SH宏
此宏定义于UnityCG.cginc中,相关代码如下:
[cpp]view plaincopy print? //包含间接漫反射的动态&静态光照贴图,所以忽略掉球面调和光照||Dynamic&Staticlightmapscontainindirectdiffuseligthing,thusignoreSH #defineUNITY_SHOULD_SAMPLE_SH(defined(LIGHTMAP_OFF)&&defined(DYNAMICLIGHTMAP_OFF))
可以发现,这个宏,其实就是将LIGHTMAP_OFF(关闭光照贴图)宏和DYNAMICLIGHTMAP_OFF(关闭动态光照贴图)宏的定义进行了封装。
3)UNITY_SAMPLE_FULL_SH_PER_PIXEL宏
UNITY_SAMPLE_FULL_SH_PER_PIXEL宏定义于UnityStandardConfig.cginc头文件中。其实也就是一个标识符,用0标示UNITY_SAMPLE_FULL_SH_PER_PIXEL宏是否已经定义。按字面上理解,启用此宏表示我们将采样计算每像素球面调和光照,而不是默认的逐顶点计算球面调和光照并且线性插值到每像素中。其实现代码如下,非常简单:
[cpp]view plaincopy print? #ifndefUNITY_SAMPLE_FULL_SH_PER_PIXEL #defineUNITY_SAMPLE_FULL_SH_PER_PIXEL0 #endif
4)ShadeSH9函数
ShadeSH9就是大家常说的球面调和函数,定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //球面调和函数 //法线需被初始化,w=1.0||normalshouldbenormalized,w=1.0 half3ShadeSH9(half4normal) { half3x1,x2,x3; //线性+常数多项式||Linear+constantpolynomialterms x1.r=dot(unity_SHAr,normal); x1.g=dot(unity_SHAg,normal); x1.b=dot(unity_SHAb,normal); //二次多项式的四个参数||4ofthequadraticpolynomials half4vB=normal.xyzz*normal.yzzx; x2.r=dot(unity_SHBr,vB); x2.g=dot(unity_SHBg,vB); x2.b=dot(unity_SHBb,vB); //最终二次多项式||Finalquadraticpolynomial halfvC=normal.x*normal.x-normal.y*normal.y; x3=unity_SHC.rgb*vC; returnx2+x3+x1; }
5)ShadeSH3Order函数
ShadeSH3Order函数,我将其翻译为三序球面调和函数。定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //三序球面调和函数 //法线需被初始化,w=1.0||normalshouldbenormalized,w=1.0 half3ShadeSH3Order(half4normal) { half3x2,x3; //二次多项式的四个参数||4ofthequadraticpolynomials half4vB=normal.xyzz*normal.yzzx; x2.r=dot(unity_SHBr,vB); x2.g=dot(unity_SHBg,vB); x2.b=dot(unity_SHBb,vB); //最终的二次多项式||Finalquadraticpolynomial halfvC=normal.x*normal.x-normal.y*normal.y; x3=unity_SHC.rgb*vC; returnx2+x3; }
6)Shade4PointLights函数
Shade4PointLights为Unity为我们准备好的逐顶点光照处理函数,定义于unityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //在正向基础渲染通道中使用,根据4个不同的点光源计算出漫反射光照参数的rgb值||UsedinForwardBasepass:Calculatesdiffuselightingfrom4pointlights,withdatapackedinaspecialway. float3Shade4PointLights( float4lightPosX,float4lightPosY,float4lightPosZ, float3lightColor0,float3lightColor1,float3lightColor2,float3lightColor3, float4lightAttenSq, float3pos,float3normal) { //【1】将输入参数转换为光照矢量||tolightvectors float4toLightX=lightPosX-pos.x; float4toLightY=lightPosY-pos.y; float4toLightZ=lightPosZ-pos.z; //【2】计算平方的值||squaredlengths float4lengthSq=0; lengthSq+=toLightX*toLightX; lengthSq+=toLightY*toLightY; lengthSq+=toLightZ*toLightZ; //【3】法线方向点乘光线方向||NdotL float4ndotl=0; ndotl+=toLightX*normal.x; ndotl+=toLightY*normal.y; ndotl+=toLightZ*normal.z; //【4】修正NdotL(法线方向点乘光线方向)的值||correctNdotL float4corr=rsqrt(lengthSq); ndotl=max(float4(0,0,0,0),ndotl*corr); //【5】计算衰减系数||attenuation float4atten=1.0/(1.0+lengthSq*lightAttenSq); float4diff=ndotl*atten; //【6】得到最终的颜色||finalcolor float3col=0; col+=lightColor0*diff.x; col+=lightColor1*diff.y; col+=lightColor2*diff.z; col+=lightColor3*diff.w; returncol; }
14. TANGENT_SPACE_ROTATION宏
TANGENT_SPACE_ROTATION宏定义于UnityCG.cginc中,作用是声明一个由切线空间的基组成的3x3矩阵,相关代码如下:
[cpp]view plaincopy print? //声明一个由切线空间的基组成的3x3矩阵||Declares3x3matrix'rotation',filledwithtangentspacebasis #defineTANGENT_SPACE_ROTATION\ float3binormal=cross(normalize(v.normal),normalize(v.tangent.xyz))*v.tangent.w;\ float3x3rotation=float3x3(v.tangent.xyz,binormal,v.normal)
也就是说,使用TANGENT_SPACE_ROTATION宏也就表示定义了上述代码所示的float3 类型的binormal和float3x3类型的rotation两个变量。且其中的rotation为3x3的矩阵,由切线空间的基组成。可以使用它把物体空间转换到切线空间中。
15.UNITY_OPTIMIZE_TEXCUBELOD宏
UNITY_OPTIMIZE_TEXCUBELOD宏的定义非常简单,就是用0标识是否开启此功能,如下所示:
[cpp]view plaincopy print? #ifndefUNITY_OPTIMIZE_TEXCUBELOD #defineUNITY_OPTIMIZE_TEXCUBELOD0 #endif
16.reflect函数
reflect函数是CG语言的内置函数。
reflect(I, N) 根据入射光方向向量I,和顶点法向量N,计算反射光方向向量。其中I 和N必须被归一化,需要特别注意的是,这个I 是指向顶点的;且此函数只对三元向量有效。
17.UNITY_TRANSFER_FOG宏
UNITY_TRANSFER_FOG宏相关代码定义于UnityCG.Cginc头文件中,用于的相关代码如下所示:
[cpp]view plaincopy print? //【0】实现不同版本的UNITY_CALC_FOG_FACTOR宏。 #ifdefined(FOG_LINEAR) //factor=(end-z)/(end-start)=z*(-1/(end-start))+(end/(end-start)) #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=(coord)*unity_FogParams.z+unity_FogParams.w #elifdefined(FOG_EXP) //factor=exp(-density*z) #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=unity_FogParams.y*(coord);unityFogFactor=exp2(-unityFogFactor) #elifdefined(FOG_EXP2) //factor=exp(-(density*z)^2) #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=unity_FogParams.x*(coord);unityFogFactor=exp2(-unityFogFactor*unityFogFactor) #else #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=0.0 #endif //【1】若已经定义了FOG_LINEAR、FOG_EXP、FOG_EXP2宏三者之中至少之一,便可以进行到此#if实现部分 #ifdefined(FOG_LINEAR)||defined(FOG_EXP)||defined(FOG_EXP2) //【1-1】定义UNITY_FOG_COORDS(idx)宏 #defineUNITY_FOG_COORDS(idx)floatfogCoord:TEXCOORD##idx; //【1-2】定义UNITY_TRANSFER_FOG(o,outpos)宏 //【1-2-1】若满足着色目标模型的版本小于ShaderModel3.0,或者定义了SHADER_API_MOBILE宏,便可以进行到此#if实现部分 //UNITY_CALC_FOG_FACTOR宏的实现见上 #if(SHADER_TARGET<30)||defined(SHADER_API_MOBILE) //移动平台和ShaderModel2.0:计算每顶点的雾效因子||mobileorSM2.0:calculatefogfactorper-vertex #defineUNITY_TRANSFER_FOG(o,outpos)UNITY_CALC_FOG_FACTOR((outpos).z);o.fogCoord=unityFogFactor //【1-2-2】否则 #else //ShaderModel3.0和PC/游戏主机平台:计算每顶点的雾距离,以及每像素雾效因子||SM3.0andPC/console:calculatefogdistanceper-vertex,andfogfactorper-pixel #defineUNITY_TRANSFER_FOG(o,outpos)o.fogCoord=(outpos).z #endif //【2】否则,直接用UNITY_FOG_COORDS宏计算雾效参数 #else #defineUNITY_FOG_COORDS(idx) #defineUNITY_TRANSFER_FOG(o,outpos) #endif
可以发现,关于此宏的定义,主要集中在如下几句:
[cpp]view plaincopy print? #if(SHADER_TARGET<30)||defined(SHADER_API_MOBILE) //移动平台和ShaderModel2.0:计算每顶点的雾效因子||mobileorSM2.0:calculatefogfactorper-vertex #defineUNITY_TRANSFER_FOG(o,outpos)UNITY_CALC_FOG_FACTOR((outpos).z);o.fogCoord=unityFogFactor //【1-2-2】否则 #else //ShaderModel3.0和PC/游戏主机平台:计算每顶点的雾距离,以及每像素雾效因子||SM3.0andPC/console:calculatefogdistanceper-vertex,andfogfactorper-pixel #defineUNITY_TRANSFER_FOG(o,outpos)o.fogCoord=(outpos).z #endif
而其中宏定义依赖的UNITY_CALC_FOG_FACTOR宏,定义于这段代码的一开头,也根据不同的场合,计算方法分为了几个版本。
OK,顶点着色器分析完篇幅都这么多了,这一节就到这里。
三、屏幕油画特效的实现
之前的文章中提出,Unity中的屏幕特效通常分为两部分来实现:
Shader实现部分脚本实现部分
下面依旧是从这两个方面对本次的特效进行实现。
3.1 Shader实现部分
依旧老规矩,先上注释好的Shader代码。
[cpp]view plaincopy print? //Reference:/view/MsXSRN# Shader"浅墨Shader编程/Volume10/OilPaintEffect" { //------------------------------------【属性值】------------------------------------ Properties { _MainTex("Base(RGB)",2D)="white"{} _Distortion("_Distortion",Range(0.0,1.0))=0.3 _ScreenResolution("_ScreenResolution",Vector)=(0.,0.,0.,0.) _ResolutionValue("_ResolutionValue",Range(0.0,5.0))=1.0 _Radius("_Radius",Range(0.0,5.0))=2.0 } //------------------------------------【唯一的子着色器】------------------------------------ SubShader { //--------------------------------唯一的通道------------------------------- Pass { //设置深度测试模式:渲染所有像素.等同于关闭透明度测试(AlphaTestOff) ZTestAlways //===========开启CG着色器语言编写模块=========== CGPROGRAM //编译指令:指定着色器编译目标为ShaderModel3.0 #pragmatarget3.0 //编译指令:告知编译器顶点和片段着色函数的名称 #pragmavertexvert #pragmafragmentfrag //包含辅助CG头文件 #include"UnityCG.cginc" //外部变量的声明 uniformsampler2D_MainTex; uniformfloat_Distortion; uniformfloat4_ScreenResolution; uniformfloat_ResolutionValue; uniformint_Radius; //顶点输入结构 structvertexInput { float4vertex:POSITION;//顶点位置 float4color:COLOR;//颜色值 float2texcoord:TEXCOORD0;//一级纹理坐标 }; //顶点输出结构 structvertexOutput { half2texcoord:TEXCOORD0;//一级纹理坐标 float4vertex:SV_POSITION;//像素位置 fixed4color:COLOR;//颜色值 }; //--------------------------------【顶点着色函数】----------------------------- //输入:顶点输入结构体 //输出:顶点输出结构体 //--------------------------------------------------------------------------------- vertexOutputvert(vertexInputInput) { //【1】声明一个输出结构对象 vertexOutputOutput; //【2】填充此输出结构 //输出的顶点位置为模型视图投影矩阵乘以顶点位置,也就是将三维空间中的坐标投影到了二维窗口 Output.vertex=mul(UNITY_MATRIX_MVP,Input.vertex); //输出的纹理坐标也就是输入的纹理坐标 Output.texcoord=Input.texcoord; //输出的颜色值也就是输入的颜色值 Output.color=Input.color; //【3】返回此输出结构对象 returnOutput; } //--------------------------------【片段着色函数】----------------------------- //输入:顶点输出结构体 //输出:float4型的颜色值 //--------------------------------------------------------------------------------- float4frag(vertexOutputInput):COLOR { //【1】根据设置的分辨率比值,计算图像尺寸 float2src_size=float2(_ResolutionValue/_ScreenResolution.x,_ResolutionValue/_ScreenResolution.y); //【2】获取坐标值 float2uv=Input.texcoord.xy; //【3】根据半径,计算出n的值 floatn=float((_Radius+1)*(_Radius+1));; //【4】定义一些参数 float3m0=0.0;float3m1=0.0; float3s0=0.0;float3s1=0.0; float3c; //【5】按半径Radius的值,迭代计算m0和s0的值 for(intj=-_Radius;j<=0;++j) { for(inti=-_Radius;i<=0;++i) { c=tex2D(_MainTex,uv+float2(i,j)*src_size).rgb; m0+=c; s0+=c*c; } } //【6】按半径Radius的值,迭代计算m1和s1的值 for(intj=0;j<=_Radius;++j) { for(inti=0;i<=_Radius;++i) { c=tex2D(_MainTex,uv+float2(i,j)*src_size).rgb; m1+=c; s1+=c*c; } } //【7】定义参数,准备计算最终的颜色值 float4finalFragColor=0.; floatmin_sigma2=1e+2; //【8】根据m0和s0,第一次计算finalFragColor的值 m0/=n; s0=abs(s0/n-m0*m0); floatsigma2=s0.r+s0.g+s0.b; if(sigma2<min_sigma2) { min_sigma2=sigma2; finalFragColor=float4(m0,1.0); } //【9】根据m1和s1,第二次计算finalFragColor的值 m1/=n; s1=abs(s1/n-m1*m1); sigma2=s1.r+s1.g+s1.b; if(sigma2<min_sigma2) { min_sigma2=sigma2; finalFragColor=float4(m1,1.0); } //【10】返回最终的颜色值 returnfinalFragColor; } ENDCG } } }
需要注意,本次油画效果的思路来自于Shadertoy中的一个油画效果的实现:/view/MsXSRN#。
此Shadertoy页面贴出的基于GLSL的Shader代码的void mainImage( out vec4 fragColor,in vec2 fragCoord )函数对应于Unity 中Shader的片段着色器。本次Shader中片段着色函数中的实现方法基本由Shadertoy中的这个OilPaint shader优化和精简而来,具体原理应该估计要翻国外的paper来写,会花费不少的时间,精力有限,在这边就暂且不细展开了。暂时只需知道这边就是在片段着色器用类似滤波的操作计算出了不同的颜色值并输出即可。
另外需要注意一点,此Shader的_Radius值越大,此Shader就越耗时,因为_Radius决定了双层循环的次数,而且是指数级的决定关系。_Radius值约小,循环的次数就会越小,从而有更快的运行效率。
3.2 C#脚本实现部分
C#脚本文件的代码几乎可以从之前的几个特效中重用,只用稍微改一点细节就可以。下面也是贴出详细注释的实现此特效的C#脚本:
[csharp]view plaincopy print? usingUnityEngine; usingSystem.Collections; //设置在编辑模式下也执行该脚本 [ExecuteInEditMode] //添加选项到菜单中 [AddComponentMenu("浅墨Shader编程/Volume10/ScreenOilPaintEffect")] publicclassScreenOilPaintEffect:MonoBehaviour { //-------------------变量声明部分------------------- #regionVariables //着色器和材质实例 publicShaderCurShader; privateMaterialCurMaterial; //两个参数值 [Range(0,5),Tooltip("分辨率比例值")] publicfloatResolutionValue=0.9f; [Range(1,30),Tooltip("半径的值,决定了迭代的次数")] publicintRadiusValue=5; //两个用于调节参数的中间变量 publicstaticfloatChangeValue; publicstaticintChangeValue2; #endregion //-------------------------材质的get&set---------------------------- #regionMaterialGetAndSet Materialmaterial { get { if(CurMaterial==null) { CurMaterial=newMaterial(CurShader); CurMaterial.hideFlags=HideFlags.HideAndDontSave; } returnCurMaterial; } } #endregion //-----------------------------------------【Start()函数】--------------------------------------------- //说明:此函数仅在Update函数第一次被调用前被调用 //-------------------------------------------------------------------------------------------------------- voidStart() { //依次赋值 ChangeValue=ResolutionValue; ChangeValue2=RadiusValue; //找到当前的Shader文件 CurShader=Shader.Find("浅墨Shader编程/Volume10/ScreenOilPaintEffect"); //判断当前设备是否支持屏幕特效 if(!SystemInfo.supportsImageEffects) { enabled=false; return; } } //-------------------------------------【OnRenderImage()函数】------------------------------------ //说明:此函数在当完成所有渲染图片后被调用,用来渲染图片后期效果 //-------------------------------------------------------------------------------------------------------- voidOnRenderImage(RenderTexturesourceTexture,RenderTexturedestTexture) { //着色器实例不为空,就进行参数设置 if(CurShader!=null) { //给Shader中的外部变量赋值 material.SetFloat("_ResolutionValue",ResolutionValue); material.SetInt("_Radius",RadiusValue); material.SetVector("_ScreenResolution",newVector4(sourceTexture.width,sourceTexture.height,0.0f,0.0f)); //拷贝源纹理到目标渲染纹理,加上我们的材质效果 Graphics.Blit(sourceTexture,destTexture,material); } //着色器实例为空,直接拷贝屏幕上的效果。此情况下是没有实现屏幕特效的 else { //直接拷贝源纹理到目标渲染纹理 Graphics.Blit(sourceTexture,destTexture); } } //-----------------------------------------【OnValidate()函数】-------------------------------------- //说明:此函数在编辑器中该脚本的某个值发生了改变后被调用 //-------------------------------------------------------------------------------------------------------- voidOnValidate() { //将编辑器中的值赋值回来,确保在编辑器中值的改变立刻让结果生效 ChangeValue=ResolutionValue; ChangeValue2=RadiusValue; } //Updateiscalledonceperframe voidUpdate() { //若程序在运行,进行赋值 if(Application.isPlaying) { //赋值 ResolutionValue=ChangeValue; RadiusValue=ChangeValue2; } //若程序没有在运行,去寻找对应的Shader文件 #ifUNITY_EDITOR if(Application.isPlaying!=true) { CurShader=Shader.Find("浅墨Shader编程/Volume10/ScreenOilPaintEffect"); } #endif } //-----------------------------------------【OnDisable()函数】--------------------------------------- //说明:当对象变为不可用或非激活状态时此函数便被调用 //-------------------------------------------------------------------------------------------------------- voidOnDisable() { if(CurMaterial) { //立即销毁材质实例 DestroyImmediate(CurMaterial); } } }
而根据脚本中参数的设定,就有分辨率和半径两个参数可以自定义条件,如下图:
下面一起看一下运行效果的对比。
四、最终的效果展示
还是那句话,贴几张场景的效果图和使用了屏幕特效后的效果图。在试玩场景时,除了类似CS/CF的FPS游戏控制系统以外,还可以使用键盘上的按键【F】,开启或者屏幕特效。
城镇一隅(with 屏幕油画特效):
城镇一隅(原始图):
城镇路口(with 屏幕油画特效):
城镇路口(原始图):
城镇一隅之二(with 屏幕油画特效):
城镇一隅之二(原始图):
木质城墙和手推车(with 屏幕油画特效):
木质城墙和手推车(原始图):
路边(with 屏幕油画特效):
路边(原始图):
图就贴这些,更多画面大家可以从文章开头下载的本文配套的exe场景,进行试玩,或者在本文附录中贴出的下载链接中下载本文配套的所有游戏资源的unitypackage。
至此,这篇博文已经1万1千多字。感谢大家的捧场。下周浅墨有些事情,所以停更一次,我们下下周,再会。
附: 本博文相关下载链接清单
【百度云】博文示例场景exe下载
【百度云】包含博文示例场景所有资源与源码的unitypackage下载
【Github】屏幕油画特效实现源码
本系列文章由@浅墨_毛星云出品,转载请注明出处。
文章链接:/poem_qianmo/article/details/49719247
作者:毛星云(浅墨)微博:/u/1723155442
本文工程使用的Unity3D版本:5.2.1
概要:本文讲解了Unity中着色器编译多样化的思路,并对Standard Shader中正向基础渲染通道的源码进行了分析,以及对屏幕油画特效进行了实现。
众所周知,Unity官方文档对Shader进阶内容的讲解是非常匮乏的。本文中对Stardard Shader源码的一些分析,全是浅墨自己通过对Shader源码的理解,以及Google之后理解与分析而来。如有解释不妥当之处,还请各位及时指出。
依然是附上一组本文配套工程的运行截图之后,便开始我们的正文。本次的选用了新的场景,正如下图中所展示的。
城镇入口(with 屏幕油画特效):
城镇入口(原始图):
图依然是贴这两张。文章末尾有更多的运行截图,并提供了源工程的下载。先放出可运行的exe下载,如下:
【可运行的本文配套exe游戏场景请点击这里下载】
提示:在此游戏场景中按F键可以开关屏幕特效。
着色器编译多样化算是Unity5中Shder书写的新特性,标准着色器之所以能独当一面,正是得益于这种特性,在这里先对此特性进行一个简单的说明与讲解。
一、关于着色器编译多样化
此部分参考自Unity5.2.1版官方文档(/Manual/SL-MultipleProgramVariants.html),经翻译&理解后而成。如有解释不妥当之处,还请各位及时指出。
Unity5中使用了一种被称为着色器编译多样化(Multiple shader program variants)的新技术,常被称为“megashaders”或“uber shaders”,并通过为每种情况提供不同的预处理指令来让着色器代码多次被编译来实现。
在Unity中,这可以通过#pragmamulti_compile或者#pragma shader_feature指令来在着色器代码段中实现。这种做法对表面着色器也可行。
在运行时,相应的着色器变体是从材质的关键词中取得的(Material.EnableKeyword和 DisableKeyword),或者全局着色器关键词(Shader.EnableKeyword和 DisableKeyword)。
1.1 multi_compile的用法简析
若我们定义如下指令:
[cpp]view plaincopy print? #pragmamulti_compileFANCY_STUFF_OFFFANCY_STUFF_ON
也就表示定义了两个变体:FANCY_STUFF_OFF和FANCY_STUFF_ON。在运行时,其中的一个将被激活,根据材质或者全局着色器关键词(#ifdef FANCY_STUFF_OFF之类的宏命令也可以)来确定激活哪个。若两个关键词都没有启用,那么将默认使用前一个选项,也就是关闭(OFF)的选项FANCY_STUFF_OFF。
需要注意,也可以存在超过两个关键字的multi_compile编译选项,比如,如下代码将产生4种着色器的变体:
[cpp]view plaincopy print? #pragmamulti_compileSIMPLE_SHADINGBETTER_SHADINGGOOD_SHADINGBEST_SHADING
当#pragma multi_compile中存在所有名字都是下划线的一个指定段时,就表示需在没有预处理宏的情况下产生一个空的着色器变种。这种做法在着色器编写中比较常见,因为这样可以在不影响使用的情况下,避免使用两个关键词,这样就节省了一个变量个数的占用(下面会提到,Unity中关键词个数是有129个的数量限制的)。例如,下面的指令将产生两个着色器变体;第一个没有定义,第二个定义为FOO_ON:
[cpp]view plaincopy print? #pragmamulti_compile__FOO_ON
这样就省去了一个本来需要定义出来的 FOO_OFF(FOO_OFF没有定义,自然也不能使用),节省了一个关键词个数的占用。
若Shader中有如上定义,则可以使用#ifdef来进行判断:
[cpp]view plaincopy print? #ifdefFOO_ON //代码段1 #endif
根据上面已经定义过的FOO_ON,此#ifdef判断的结果为真,代码段1部分的代码就会被执行到。反之,若#pragma multi_compile __FOO_ON一句代码没有交代出来,那么代码段1部分的代码就不会被执行。
这就是着色器编译多样化的实现方式,其实理解起来很容易,对吧。
1.2 shader_feature和multi_compile之间的区别
#pragma shader_feature 和#pragma multi_compile非常相似,唯一的区别在于采用了#pragmashader_feature语义的shader,在遇到不被使用的变体的时候,就不会将其编译到游戏中。所以,shader_feature中使得所有的设置到材质中的关键词都是有效的,而multi_compile指令将从全局代码里设置关键词。
另外,shader_feature还有一个仅仅含有一个关键字的快捷表达方式,例如:
[cpp]view plaincopy print? #pragmashader_featureFANCY_STUFF
此为#pragma shader_feature _ FANCY_STUFF的一个简写形式,其扩展出了两个着色器变体,第一种变体自然为不定此FANCY_STUFF变量(那么若在稍后的Shader代码中进行#ifdef FANCY_STUFF的判断,则结果为假),第二种变体为定义此FANCY_STUFF变量(此情况下#ifdef FANCY_STUFF的判断结果为真)。
1.3 多个multi_compile连用会造成指数型增长
可以提供多个multi_compile流水线,然后着色器的结果可以被编译为几个流水线的排列组合,比如:
[cpp]view plaincopy print? #pragmamulti_compileABC #pragmamulti_compileDE
第一行中有3种选项,第二行中有两种选项,那么进行排列组合,总共就会有六种选项(A+D, B+D, C+D, A+E, B+E, C+E)。
容易想到,一般每以个multi_compile流水线,都控制着着色器中某一单一的特性。请注意,着色器总量的增长速度是非常快的。
比如,10条包含两个特性的multi_compil指令,会得到2的10次方,也就是1024种不同的着色器变体。
1.4 关于Unity中的关键词限制Keyword limit
当使用着色变量时,我们应该记住,Unity中将关键词的数量限制在了128个之内(着色变量算作关键字),且其中有一些已经被Unity内置使用了,因此,我们真正可以自定义使用关键词的数量以及是小于128个的。同时,关键词是在单个Unity项目中全局使用并计数的,所以我们要千万小心,在同一项目中存在的但没用到Shader也要考虑在内,千万不要合起来在数量上超出Unity的关键词数量限制了。
1.5 Unity内置的快捷multi_compile指令
如下有Unity内置的几个着色器变体的快捷多编译指令,他们大多是应对Unity中不同的光线,阴影和光照贴图类型。详情见rendering pipeline。
multi_compile_fwdbase- 此指令表示,编译正向基础渲染通道(用于正向渲染中,应用环境光照、主方向光照和顶点/球面调和光照(Spherical Harmonic Lighting))所需的所有变体。这些变体用于处理不同的光照贴图类型、主要方向光源的阴影选项的开关与否。multi_compile_fwdadd- 此指令表示, 编译正向附加渲染通道(用于正向渲染中;以每个光照一个通道的方式应用附加的逐像素光照)所需的所有变体。这些变体用于处理光源的类型(方向光源、聚光灯或者点光源),且这些变种都包含纹理cookie。multi_compile_fwdadd_fullshadows– 此指令和上面的正向渲染附加通道基本一致,但同时为上述通道的处理赋予了光照实时阴影的能力。multi_compile_fog- 此指令表示,编译出几个不同的Shader变体来处理不同类型的雾效(关闭/线性/指数/二阶指数)(off/linear/exp/exp2).
1.6 使用指令跳过某些变体的编译
大多数内置的快捷指令导致了很多着色的变体。若我们熟悉他们且知道有些并非所需,可以使用#pragmaskip_variants语句跳过其中一些的编译。例如:
[cpp]view plaincopy print? #pragmamulti_compile_fwdadd //将跳过所有使用"POINT"或"POINT_COOKIE"的变体 #pragmaskip_variantsPOINTPOINT_COOKIE
OK,通过上面经过翻译&理解过后的官方文档材料,应该对Unity中的着色器编译多样化有了一个理解。说白了,着色器变体的定义和使用与宏定义很类似。
1.7 对知识的提炼
上面交代了这么多,看不懂没关系,我们提炼一下,看懂这段提炼,关于着色器变体的意义与使用方式,也就懂了大半了。
若我们在着色器中定义了这一句:
[cpp]view plaincopy print? #pragmashader_feature_THIS_IS_A_SAMPLE
这句代码理解起来,也就是_THIS_IS_A_SAMPLE被我们定义过了,它是存在的,以后我们如果判断#ifdef _THIS_IS_A_SAMPLE,那就是真了。我们可以在这个判断的#ifdef…… #endif块里面实现自己需要的实现代码X,这段实现代码X,只会在你用#pragma multi_compile 或#pragmashader_feature定义了_THIS_IS_A_SAMPLE这个“宏”的时候会被执行,否则,它就不会被执行到。
实现代码X的执行与不执行,全靠你对变体的定义与否。这就是着色器编译多样化的实现方式,一个着色器+多个CG头文件的小团队(如标准着色器),可以独当一面,一个打一群,可以取代一大堆独立实现的Shader的原因所在。
二、Standard Shader中正向基础渲染通道源码分析
这一节主要用来解析Standard Shader中正向基础渲染通道的源码。
先上Standard Shader正向渲染基础通道(Shader Model 3.0版)的Shader源代码:
[cpp]view plaincopy print? //------------------------------------【子着色器1】------------------------------------ //此子着色器用于ShaderModel3.0 //---------------------------------------------------------------------------------------- SubShader { //渲染类型设置:不透明 Tags{"RenderType"="Opaque""PerformanceChecks"="False"} //细节层次设为:300 LOD300 //--------------------------------通道1------------------------------- //正向基础渲染通道(Baseforwardpass) //处理方向光,自发光,光照贴图等... Pass { //设置通道名称 Name"FORWARD" //于通道标签中设置光照模型为ForwardBase,正向渲染基础通道 Tags{"LightMode"="ForwardBase"} //混合操作:源混合乘以目标混合 Blend[_SrcBlend][_DstBlend] //根据_ZWrite参数,设置深度写入模式开关与否 ZWrite[_ZWrite] //===========开启CG着色器语言编写模块=========== CGPROGRAM //着色器编译目标:Model3.0 #pragmatarget3.0 //编译指令:不使用GLES渲染器编译 #pragmaexclude_renderersgles //---------编译指令:着色器编译多样化-------- #pragmashader_feature_NORMALMAP #pragmashader_feature__ALPHATEST_ON_ALPHABLEND_ON_ALPHAPREMULTIPLY_ON #pragmashader_feature_EMISSION #pragmashader_feature_METALLICGLOSSMAP #pragmashader_feature____DETAIL_MULX2 #pragmashader_feature_PARALLAXMAP //--------着色器编译多样化快捷指令------------ //编译指令:编译正向渲染基础通道(用于正向渲染中,应用环境光照、主方向光照和顶点/球面调和光照)所需的所有变体。 //这些变体用于处理不同的光照贴图类型、主要方向光源的阴影选项的开关与否 #pragmamulti_compile_fwdbase //编译指令:编译几个不同变种来处理不同类型的雾效(关闭/线性/指数/二阶指数/) #pragmamulti_compile_fog //编译指令:告知编译器顶点和片段着色函数的名称 #pragmavertexvertForwardBase #pragmafragmentfragForwardBase //包含辅助CG头文件 #include"UnityStandardCore.cginc" //===========结束CG着色器语言编写模块=========== ENDCG } …… }
OK,一起来稍微分析一下上述代码。基本上是逐行注释,所以找几个容易疑惑的点来提一下。
第一处,着色器编译多样化部分,代码如下:
[cpp]view plaincopy print? //---------编译指令:着色器编译多样化-------- #pragmashader_feature_NORMALMAP #pragmashader_feature__ALPHATEST_ON_ALPHABLEND_ON_ALPHAPREMULTIPLY_ON #pragmashader_feature_EMISSION #pragmashader_feature_METALLICGLOSSMAP #pragmashader_feature____DETAIL_MULX2 #pragmashader_feature_PARALLAXMAP
上文刚讲过着色器编译多样化的一些理解,理解起来就是这样,这边定义了很多的“宏”、 _NORMALMAP、_ALPHATEST_ON、_ALPHABLEND_ON、_EMISSION、_METALLICGLOSSMAP、_DETAIL_MULX2、_PARALLAXMAP,在顶点和片段着色器实现部分,可以用#ifdef _EMISSION类似的宏命令来对不同情况下的实现进行区别对待。
第二处,着色器编译多样化快捷指令部分,上文的讲解部分也有分别提到,这里代码注释已经很详细,如下:
[cpp]view plaincopy print? //--------着色器编译多样化快捷指令------------ //编译指令:编译正向渲染基础通道(用于正向渲染中,应用环境光照、主方向光照和顶点/球面调和光照)所需的所有变体。 //这些变体用于处理不同的光照贴图类型、主要方向光源的阴影选项的开关与否 #pragmamulti_compile_fwdbase //编译指令:编译几个不同变种来处理不同类型的雾效(关闭/线性/指数/二阶指数/) #pragmamulti_compile_fog
第三处,顶点着色函数和片段着色函数声明部分,代码如下:
[cpp]view plaincopy print? //编译指令:告知编译器顶点和片段着色函数的名称 #pragmavertexvertForwardBase #pragmafragmentfragForwardBase
这里比较关键,指明了这个pass中顶点着色函数和片段着色函数分别是名为vertForwardBase和fragForwardBase的函数。而这两个函数定义于何处?看包含头文件是什么即可。一起来看一下第四处。
第四处,CG头文件包含部分,代码如下:
[cpp]view plaincopy print? //包含辅助CG头文件 #include"UnityStandardCore.cginc"
很简单的一句话,但却像一切编程语言中头文件的包含一样,非常关键,不能缺少。vertForwardBase和 fragForwardBase的函数全都定义于此“UnityStandardCore.cginc”头文件中。
OK,我们转到“UnityStandardCore.cginc”头文件,继续分析下去。先从vertForwardBase函数开始。
1.顶点着色函数——vertForwardBase
vertForwardBase函数也已详细注释好,代码如下:
[cpp]view plaincopy print? //-----------------------------------【vertForwardBase函数】---------------------------------------- //用途:正向渲染基础通道的顶点着色函数 //说明:实例化一个VertexOutputForwardBase结构体对象,并进行相应的填充 //输入:VertexInput结构体 //输出:VertexOutputForwardBase结构体 //附:VertexInput结构体原型: /* structVertexInput {float4vertex:POSITION; half3normal:NORMAL; float2uv0:TEXCOORD0; float2uv1:TEXCOORD1; #ifdefined(DYNAMICLIGHTMAP_ON)||defined(UNITY_PASS_META) float2uv2:TEXCOORD2; #endif #ifdef_TANGENT_TO_WORLD half4tangent:TANGENT; #endif }; */ //--------------------------------------------------------------------------------------------------------- VertexOutputForwardBasevertForwardBase(VertexInputv) { //【1】实例化一个VertexOutputForwardBase结构体对象 VertexOutputForwardBaseo; //用Unity内置的宏初始化参数 UNITY_INITIALIZE_OUTPUT(VertexOutputForwardBase,o); //【2】通过物体坐标系到世界坐标系的变换矩阵乘以物体的顶点位置,得到对象在世界坐标系中的位置 float4posWorld=mul(_Object2World,v.vertex); //【3】若定义了镜面立方体投影宏,将计算得到的世界坐标系的xyz坐标作为输出参数的世界坐标值 #ifUNITY_SPECCUBE_BOX_PROJECTION o.posWorld=posWorld.xyz; #endif //【4】输出的顶点位置(像素位置)为模型视图投影矩阵乘以顶点位置,也就是将三维空间中的坐标投影到了二维窗口 o.pos=mul(UNITY_MATRIX_MVP,v.vertex); //【5】计算纹理坐标,使用UnityStandardInput.cginc头文件中的辅助函数。 o.tex=TexCoords(v); //【6】视线的方向=对象在世界坐标系中的位置减去摄像机的世界空间位置,并进行逐顶点归一化 o.eyeVec=NormalizePerVertexNormal(posWorld.xyz-_WorldSpaceCameraPos); //【7】计算物体在世界空间中的法线坐标 float3normalWorld=UnityObjectToWorldNormal(v.normal); //【8】进行世界空间中的切线相关参数的计算与赋值 //若定义了_TANGENT_TO_WORLD #ifdef_TANGENT_TO_WORLD //世界空间中的物体的法线值 float4tangentWorld=float4(UnityObjectToWorldDir(v.tangent.xyz),v.tangent.w); //在世界空间中为每个顶点创建切线 float3x3tangentToWorld=CreateTangentToWorldPerVertex(normalWorld,tangentWorld.xyz,tangentWorld.w); //分别为3个分量赋值 o.tangentToWorldAndParallax[0].xyz=tangentToWorld[0]; o.tangentToWorldAndParallax[1].xyz=tangentToWorld[1]; o.tangentToWorldAndParallax[2].xyz=tangentToWorld[2]; //否则,三个分量直接取为0,0和上面计算得到的normalWorld #else o.tangentToWorldAndParallax[0].xyz=0; o.tangentToWorldAndParallax[1].xyz=0; o.tangentToWorldAndParallax[2].xyz=normalWorld; #endif //【9】阴影的获取 TRANSFER_SHADOW(o); //【10】进行顶点正向相关的全局光照操作 o.ambientOrLightmapUV=VertexGIForward(v,posWorld,normalWorld); //【11】若定义了_PARALLAXMAP宏,则计算视差的视角方向并赋值 #ifdef_PARALLAXMAP //声明一个由切线空间的基组成的3x3矩阵“rotation” TANGENT_SPACE_ROTATION; //计算视差的视角方向 half3viewDirForParallax=mul(rotation,ObjSpaceViewDir(v.vertex)); //分别将三个分量赋值给VertexOutputForwardBase结构体对象o的tangentToWorldAndParallax的三个分量 o.tangentToWorldAndParallax[0].w=viewDirForParallax.x; o.tangentToWorldAndParallax[1].w=viewDirForParallax.y; o.tangentToWorldAndParallax[2].w=viewDirForParallax.z; #endif //【12】若定义了UNITY_OPTIMIZE_TEXCUBELOD,便计算反射光方向向量并赋值 #ifUNITY_OPTIMIZE_TEXCUBELOD //使用CG语言内置函数reflect计算反射光方向向量 o.reflUVW=reflect(o.eyeVec,normalWorld); #endif //【13】从顶点中输出雾数据 UNITY_TRANSFER_FOG(o,o.pos); //【14】返回已经附好值的VertexOutputForwardBase类型的对象 returno; }
基本步骤已经在代码注释中用序号列出,以下将对其中的主要知识点进行讲解。首先看一下函数的输出参数——VertexInput。
2.顶点输入结构体——VertexInput
此结构体定义于UnityStandardInput.cginc头文件中,是顶点着色函数vertForwardBase的输入参数,相关代码如下所示:
[cpp]view plaincopy print? //顶点输入结构体 structVertexInput { float4vertex:POSITION;//位置坐标 half3normal:NORMAL;//法线向量 float2uv0:TEXCOORD0;//一级纹理坐标 float2uv1:TEXCOORD1;//二级纹理坐标 //若DYNAMICLIGHTMAP_ON或者UNITY_PASS_META选项为开,则还定义一个三级纹理 #ifdefined(DYNAMICLIGHTMAP_ON)||defined(UNITY_PASS_META) float2uv2:TEXCOORD2;//三级纹理 #endif #ifdef_TANGENT_TO_WORLD half4tangent:TANGENT;//切线向量 #endif };
此结构体比较通用,不仅仅是用于正向基础渲染通道,毕竟是定义在UnityStandardInput.cginc头文件中的。
各个变量的含义,注释中已经写到了,好像没有什么值得多说的,再来看下顶点输出结构体。
3.顶点输出结构体——VertexOutputForwardBase
顾名思义,VertexOutputForwardBase结构体就是正向基础渲染通道特有的输出结构体,定义于UnityStandardCore.cginc头文件中,注释后的代码如下:
[cpp]view plaincopy print? //正向渲染基础通道的输出结构体 structVertexOutputForwardBase { float4pos:SV_POSITION;//像素坐标 float4tex:TEXCOORD0;//一级纹理 half3eyeVec:TEXCOORD1;//二级纹理(视线向量) half4tangentToWorldAndParallax[3]:TEXCOORD2;//3x3为切线到世界矩阵的值,1x3为视差方向的值 half4ambientOrLightmapUV:TEXCOORD5;//球谐函数(Sphericalharmonics)或光照贴图的UV坐标 SHADOW_COORDS(6)//阴影坐标 UNITY_FOG_COORDS(7)//雾效坐标 //若定义了镜面立方体投影宏,定义一个posWorld #ifUNITY_SPECCUBE_BOX_PROJECTION float3posWorld:TEXCOORD8; #endif //若定义了优化纹理的立方体LOD宏,还将定义如下的参数reflUVW #ifUNITY_OPTIMIZE_TEXCUBELOD #ifUNITY_SPECCUBE_BOX_PROJECTION half3reflUVW:TEXCOORD9; #else half3reflUVW:TEXCOORD8; #endif #endif };
从这里开始,做一个规定,为了方便对照和理解,以下贴出代码中也会贴出原始的英文注释——先翻译为中文,以 || 结束,在 || 后附上原始的英文。
就像这样:
//最终的二次多项式 || Final quadraticpolynomial
OK,我们继续,vertForwardBase函数中有很多知识点值得拿出来讲一讲的。
4. UNITY_INITIALIZE_OUTPUT宏
UNITY_INITIALIZE_OUTPUT(type,name)–此宏用于将给定类型的名称变量初始化为零。在使用旧版标准所写的Shader时,经常会报错“Try adding UNITY_INITIALIZE_OUTPUT(Input,o); like this in your vertfunction.”之类的错误,加上这句就不会报错了。
5._Object2World矩阵
_Object2World,Unity的内置矩阵,世界坐标系到对象坐标系的变换矩阵,简称“世界-对象矩阵”。
6.UNITY_MATRIX_MVP矩阵
UNITY_MATRIX_MVP为当前的模型矩阵x视图矩阵x投影矩阵,简称“模型-视图-投影矩阵”。其常用于在顶点着色函数中,通过将它和顶点位置相乘,从而可以把顶点位置从模型空间转换到裁剪空间(clip space)中。也就是通过此矩阵,将三维空间中的坐标投影到了二维窗口中。
7.TexCoords函数
TexCoords函数用于获取纹理坐标,定义UnityStandardInput.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? float4TexCoords(VertexInputv) { float4texcoord; texcoord.xy=TRANSFORM_TEX(v.uv0,_MainTex);//Alwayssourcefromuv0 texcoord.zw=TRANSFORM_TEX(((_UVSec==0)?v.uv0:v.uv1),_DetailAlbedoMap); returntexcoord; }
函数实现代码中的_MainTex、_UVSec、_DetailAlbedoMap都是此头文件定义的全局的变量。
其中还涉及到了一个TRANSFORM_TEX宏,在这边也提一下,它定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //按比例和偏移进行二维UV坐标的变换 #defineTRANSFORM_TEX(tex,name)(tex.xy*name##_ST.xy+name##_ST.zw)
8. NormalizePerVertexNormal函数
此函数位于unitystandardcore.cginc头文件中,原型和注释如下:
[cpp]view plaincopy print? //--------------------------【函数NormalizePerVertexNormal】----------------------------- //用途:归一化每顶点法线 //说明:若满足特定条件,便归一化每顶点法线并返回,否则,直接返回原始值 //输入:half3类型的法线坐标 //输出:若满足判断条件,返回half3类型的、经过归一化后的法线坐标,否则返回输入的值 //----------------------------------------------------------------------------------------------- half3NormalizePerVertexNormal(half3n) { //满足着色目标模型的版本小于ShaderModel3.0,或者定义了UNITY_STANDARD_SIMPLE宏,返回归一化后的值 #if(SHADER_TARGET<30)||UNITY_STANDARD_SIMPLE returnnormalize(n); //否则,直接返回输入的参数,后续应该会进行逐像素的归一化 #else returnn; #endif }
其中,SHADER_TARGET宏代表的值为和着色器的目标编译模型(shader model)相关的一个数值。
例如,当着色器编译成Shader Model 3.0时,SHADER_TARGET 便为30。我们可以在shader代码中由此来进行条件判断。相关代码如下:
[cpp]view plaincopy print? #ifSHADER_TARGET<30 //实现代码A #else //实现代码B #endif
9. UnityObjectToWorldNormal函数
UnityObjectToWorldNormal是Unity内置的函数,可以将法线从模型空间变换到世界空间中,定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //将法线从模型空间变换到世界空间 inlinefloat3UnityObjectToWorldNormal(infloat3norm) { //将分量分别相乘,并进行归一化 //Multiplybytransposedinversematrix,actuallyusingtranspose()generatesbadlyoptimizedcode returnnormalize(_World2Object[0].xyz*norm.x+_World2Object[1].xyz*norm.y+_World2Object[2].xyz*norm.z); }
而其中的normalize( )函数太常见不过了,是来自CG语言中的函数,作用是归一化向量。
10.UnityObjectToWorldDir函数
UnityObjectToWorldDir函数用于方向值从物体空间切换到世界空间,也定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //将方向值从物体空间切换到世界空间 inlinefloat3UnityObjectToWorldDir(infloat3dir) { returnnormalize(mul((float3x3)_Object2World,dir)); }
可以看到,就是返回一个世界-对象矩阵乘以方向值归一化后的结果,比较好理解。
11. CreateTangentToWorldPerVertex函数
CreateTangentToWorldPerVertex函数用于在世界空间中为每个顶点创建切线,定义于UnityStandardUtils.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? half3x3CreateTangentToWorldPerVertex(half3normal,half3tangent,halftangentSign) { //对于奇数负比例变换,我们需要将符号反向||<spanstyle="font-family:Arial,Helvetica,sans-serif;">Forodd-negativescaletransformsweneedtoflipthesign</span> halfsign=tangentSign*unity_WorldTransformParams.w; half3binormal=cross(normal,tangent)*sign; returnhalf3x3(tangent,binormal,normal); }
其中的unity_WorldTransformParams是UnityShaderVariables.cginc头文件中定义的一个uniform float4型的变量,其w分量用于标定奇数负比例变换(odd-negativescale transforms),通常取值为1.0或者-1.0。
12.TRANSFER_SHADOW(a)宏
此宏用于进行阴影在各种空间中的转换,定义于AutoLight.cginc中。在不同的情况下,此宏代表的意义并不相同。下面简单进行下展开分析。
1)对于屏幕空间中的阴影(Screen space shadows)
对应于屏幕空间中的阴影,也就是#if defined (SHADOWS_SCREEN),其相关代码如下:
[cpp]view plaincopy print? #ifdefined(SHADOWS_SCREEN) …… #ifdefined(UNITY_NO_SCREENSPACE_SHADOWS) #defineTRANSFER_SHADOW(a)a._ShadowCoord=mul(unity_World2Shadow[0],mul(_Object2World,v.vertex)); #else//notUNITY_NO_SCREENSPACE_SHADOWS #defineTRANSFER_SHADOW(a)a._ShadowCoord=ComputeScreenPos(a.pos); …… #endif
也就是说,这种情况下的TRANSFER_SHADOW(a)宏,代表了一句代码,这句代码就是a._ShadowCoord = mul (unity_World2Shadow[0],mul(_Object2World,v.vertex));
此句代码的含义是:将世界-阴影坐标乘以世界-模型坐标和物体顶点坐标的积,也就是先将物体坐标转换成世界坐标,再将世界坐标转换成阴影坐标,并将结果存放于a._ShadowCoord中。
2)对于聚光灯阴影(Spot light shadows)
而对于聚光灯的阴影,也就是#if defined (SHADOWS_DEPTH)&& defined (SPOT)
有如下定义:
[cpp]view plaincopy print? #ifdefined(SHADOWS_DEPTH)&&defined(SPOT) #defineTRANSFER_SHADOW(a)a._ShadowCoord=mul(unity_World2Shadow[0],mul(_Object2World,v.vertex)); …… #endif
可以发现,这种情况下的TRANSFER_SHADOW(a)宏代表的语句也是a._ShadowCoord = mul (unity_World2Shadow[0],mul(_Object2World,v.vertex));
同上,用途就是先将物体坐标转换成世界坐标,再将世界坐标转换成阴影坐标,并将结果存放于a._ShadowCoord中。
3)对于点光源阴影(Point light shadows)
而对于点光源的阴影,也就是#if defined (SHADOWS_CUBE),有如下定义:
[cpp]view plaincopy print? #ifdefined(SHADOWS_CUBE) #defineTRANSFER_SHADOW(a)a._ShadowCoord=mul(_Object2World,v.vertex).xyz-_LightPositionRange.xyz; …… #endif
也就是说,这种情况下的TRANSFER_SHADOW(a)宏代表语句a._ShadowCoord = mul(_Object2World, v.vertex).xyz -_LightPositionRange.xyz;
想了解此代码的含义,先要知道_LightPositionRange变量的含义。
这个变量是UnityShaderVariables.cginc头文件中定义的一个全局变量:
[cpp]view plaincopy print? uniformfloat4_LightPositionRange;//xyz=pos,w=1/range
从英文注释可以发现,此参数的x,y,z分量表示世界空间下光源的坐标,而w为世界空间下范围的倒数。
那么此句代码的含义,也就是先将物体-世界矩阵乘以物体顶点坐标,得到物体的世界空间坐标,然后取坐标的xyz分量,与光源的坐标相减,并将结果赋给a._ShadowCoord。
4)对于关闭阴影(Shadows off)的情况
而对于关闭阴影的情况,也就是#if !defined (SHADOWS_SCREEN)&& !defined (SHADOWS_DEPTH) && !defined (SHADOWS_CUBE),有如下定义:
[cpp]view plaincopy print? #if!defined(SHADOWS_SCREEN)&&!defined(SHADOWS_DEPTH)&&!defined(SHADOWS_CUBE) #defineTRANSFER_SHADOW(a) …… #endif
这种情况下的TRANSFER_SHADOW(a)宏代表的是空白,并没有什么用。
13. VertexGIForward函数
定义于UnityStandardCore.cginc头文件中。详细注释后的代码如下:
[cpp]view plaincopy print? //顶点正向全局光照函数 inlinehalf4VertexGIForward(VertexInputv,float3posWorld,half3normalWorld) { //【1】定义一个half4型的ambientOrLightmapUV变量,并将四个分量都置为0 half4ambientOrLightmapUV=0; //【2】对ambientOrLightmapUV变量的四个分量赋值 //【2-1】若没有定义LIGHTMAP_OFF(关闭光照贴图)宏,也就是此情况下启用静态的光照贴图,则计算对应的光照贴图坐标 //staticlightmap #ifndefLIGHTMAP_OFF ambientOrLightmapUV.xy=v.uv1.xy*unity_LightmapST.xy+unity_LightmapST.zw; ambientOrLightmapUV.zw=0; //【2-2】若定义了UNITY_SHOULD_SAMPLE_SH宏,则表示对动态的对象采样(不对静态或者动态的光照贴图采样) //||SamplelightprobeforDynamicobjectsonly(nostaticordynamiclightmaps) #elifUNITY_SHOULD_SAMPLE_SH //【2-2-1】若定义了如下的UNITY_SAMPLE_FULL_SH_PER_PIXEL宏(即采样计算全部的每像素球面调和光照),便给ambientOrLightmapUV.rgb赋值为0 #ifUNITY_SAMPLE_FULL_SH_PER_PIXEL ambientOrLightmapUV.rgb=0; //【2-2-2】若满足着色目标模型的版本小于ShaderModel3.0,或者定义了UNITY_STANDARD_SIMPLE宏 //便使用球面调和函数ShadeSH9给ambientOrLightmapUV.rgb赋值 #elif(SHADER_TARGET<30)||UNITY_STANDARD_SIMPLE ambientOrLightmapUV.rgb=ShadeSH9(half4(normalWorld,1.0)); //【2-2-3】否则,使用三序球面调和函数ShadeSH3Order给ambientOrLightmapUV.rgb赋值 #else //优化操作:光源L0、L1逐像素,光源L2逐顶点||Optimization:L2per-vertex,L0..L1per-pixel ambientOrLightmapUV.rgb=ShadeSH3Order(half4(normalWorld,1.0)); #endif //【2-2-4】从非重要的点光源中添加近似的照明||Addapproximatedilluminationfromnon-importantpointlights //若定义了如下的VERTEXLIGHT_ON宏(即开启顶点光照),便使用Shade4PointLights函数给ambientOrLightmapUV.rgb赋值,添加环境光 #ifdefVERTEXLIGHT_ON //Shade4PointLights为Unity内置的逐顶点光照处理函数,定义于unityCG.cginc头文件中 ambientOrLightmapUV.rgb+=Shade4PointLights( unity_4LightPosX0,unity_4LightPosY0,unity_4LightPosZ0, unity_LightColor[0].rgb,unity_LightColor[1].rgb,unity_LightColor[2].rgb,unity_LightColor[3].rgb, unity_4LightAtten0,posWorld,normalWorld); #endif #endif //【2-3】若定义了如下的VERTEXLIGHT_ONDYNAMICLIGHTMAP_ON宏(即开启动态光照贴图),则给变量的zw分量赋值 #ifdefDYNAMICLIGHTMAP_ON ambientOrLightmapUV.zw=v.uv2.xy*unity_DynamicLightmapST.xy+unity_DynamicLightmapST.zw; #endif //【3】返回ambientOrLightmapUV变量的值 returnambientOrLightmapUV; }
其中有一些小的点,这边提出来讲一下。
1)unity_LightmapST变量
unity_LightmapST变量类型为float4型,定义于UnityShaderVariables.cginc头文件中,存放着光照贴图操作的参数的值:
[cpp]view plaincopy print? float4unity_LightmapST;
2)UNITY_SHOULD_SAMPLE_SH宏
此宏定义于UnityCG.cginc中,相关代码如下:
[cpp]view plaincopy print? //包含间接漫反射的动态&静态光照贴图,所以忽略掉球面调和光照||Dynamic&Staticlightmapscontainindirectdiffuseligthing,thusignoreSH #defineUNITY_SHOULD_SAMPLE_SH(defined(LIGHTMAP_OFF)&&defined(DYNAMICLIGHTMAP_OFF))
可以发现,这个宏,其实就是将LIGHTMAP_OFF(关闭光照贴图)宏和DYNAMICLIGHTMAP_OFF(关闭动态光照贴图)宏的定义进行了封装。
3)UNITY_SAMPLE_FULL_SH_PER_PIXEL宏
UNITY_SAMPLE_FULL_SH_PER_PIXEL宏定义于UnityStandardConfig.cginc头文件中。其实也就是一个标识符,用0标示UNITY_SAMPLE_FULL_SH_PER_PIXEL宏是否已经定义。按字面上理解,启用此宏表示我们将采样计算每像素球面调和光照,而不是默认的逐顶点计算球面调和光照并且线性插值到每像素中。其实现代码如下,非常简单:
[cpp]view plaincopy print? #ifndefUNITY_SAMPLE_FULL_SH_PER_PIXEL #defineUNITY_SAMPLE_FULL_SH_PER_PIXEL0 #endif
4)ShadeSH9函数
ShadeSH9就是大家常说的球面调和函数,定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //球面调和函数 //法线需被初始化,w=1.0||normalshouldbenormalized,w=1.0 half3ShadeSH9(half4normal) { half3x1,x2,x3; //线性+常数多项式||Linear+constantpolynomialterms x1.r=dot(unity_SHAr,normal); x1.g=dot(unity_SHAg,normal); x1.b=dot(unity_SHAb,normal); //二次多项式的四个参数||4ofthequadraticpolynomials half4vB=normal.xyzz*normal.yzzx; x2.r=dot(unity_SHBr,vB); x2.g=dot(unity_SHBg,vB); x2.b=dot(unity_SHBb,vB); //最终二次多项式||Finalquadraticpolynomial halfvC=normal.x*normal.x-normal.y*normal.y; x3=unity_SHC.rgb*vC; returnx2+x3+x1; }
5)ShadeSH3Order函数
ShadeSH3Order函数,我将其翻译为三序球面调和函数。定义于UnityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //三序球面调和函数 //法线需被初始化,w=1.0||normalshouldbenormalized,w=1.0 half3ShadeSH3Order(half4normal) { half3x2,x3; //二次多项式的四个参数||4ofthequadraticpolynomials half4vB=normal.xyzz*normal.yzzx; x2.r=dot(unity_SHBr,vB); x2.g=dot(unity_SHBg,vB); x2.b=dot(unity_SHBb,vB); //最终的二次多项式||Finalquadraticpolynomial halfvC=normal.x*normal.x-normal.y*normal.y; x3=unity_SHC.rgb*vC; returnx2+x3; }
6)Shade4PointLights函数
Shade4PointLights为Unity为我们准备好的逐顶点光照处理函数,定义于unityCG.cginc头文件中,相关代码如下:
[cpp]view plaincopy print? //在正向基础渲染通道中使用,根据4个不同的点光源计算出漫反射光照参数的rgb值||UsedinForwardBasepass:Calculatesdiffuselightingfrom4pointlights,withdatapackedinaspecialway. float3Shade4PointLights( float4lightPosX,float4lightPosY,float4lightPosZ, float3lightColor0,float3lightColor1,float3lightColor2,float3lightColor3, float4lightAttenSq, float3pos,float3normal) { //【1】将输入参数转换为光照矢量||tolightvectors float4toLightX=lightPosX-pos.x; float4toLightY=lightPosY-pos.y; float4toLightZ=lightPosZ-pos.z; //【2】计算平方的值||squaredlengths float4lengthSq=0; lengthSq+=toLightX*toLightX; lengthSq+=toLightY*toLightY; lengthSq+=toLightZ*toLightZ; //【3】法线方向点乘光线方向||NdotL float4ndotl=0; ndotl+=toLightX*normal.x; ndotl+=toLightY*normal.y; ndotl+=toLightZ*normal.z; //【4】修正NdotL(法线方向点乘光线方向)的值||correctNdotL float4corr=rsqrt(lengthSq); ndotl=max(float4(0,0,0,0),ndotl*corr); //【5】计算衰减系数||attenuation float4atten=1.0/(1.0+lengthSq*lightAttenSq); float4diff=ndotl*atten; //【6】得到最终的颜色||finalcolor float3col=0; col+=lightColor0*diff.x; col+=lightColor1*diff.y; col+=lightColor2*diff.z; col+=lightColor3*diff.w; returncol; }
14. TANGENT_SPACE_ROTATION宏
TANGENT_SPACE_ROTATION宏定义于UnityCG.cginc中,作用是声明一个由切线空间的基组成的3x3矩阵,相关代码如下:
[cpp]view plaincopy print? //声明一个由切线空间的基组成的3x3矩阵||Declares3x3matrix'rotation',filledwithtangentspacebasis #defineTANGENT_SPACE_ROTATION\ float3binormal=cross(normalize(v.normal),normalize(v.tangent.xyz))*v.tangent.w;\ float3x3rotation=float3x3(v.tangent.xyz,binormal,v.normal)
也就是说,使用TANGENT_SPACE_ROTATION宏也就表示定义了上述代码所示的float3 类型的binormal和float3x3类型的rotation两个变量。且其中的rotation为3x3的矩阵,由切线空间的基组成。可以使用它把物体空间转换到切线空间中。
15.UNITY_OPTIMIZE_TEXCUBELOD宏
UNITY_OPTIMIZE_TEXCUBELOD宏的定义非常简单,就是用0标识是否开启此功能,如下所示:
[cpp]view plaincopy print? #ifndefUNITY_OPTIMIZE_TEXCUBELOD #defineUNITY_OPTIMIZE_TEXCUBELOD0 #endif
16.reflect函数
reflect函数是CG语言的内置函数。
reflect(I, N) 根据入射光方向向量I,和顶点法向量N,计算反射光方向向量。其中I 和N必须被归一化,需要特别注意的是,这个I 是指向顶点的;且此函数只对三元向量有效。
17.UNITY_TRANSFER_FOG宏
UNITY_TRANSFER_FOG宏相关代码定义于UnityCG.Cginc头文件中,用于的相关代码如下所示:
[cpp]view plaincopy print? //【0】实现不同版本的UNITY_CALC_FOG_FACTOR宏。 #ifdefined(FOG_LINEAR) //factor=(end-z)/(end-start)=z*(-1/(end-start))+(end/(end-start)) #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=(coord)*unity_FogParams.z+unity_FogParams.w #elifdefined(FOG_EXP) //factor=exp(-density*z) #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=unity_FogParams.y*(coord);unityFogFactor=exp2(-unityFogFactor) #elifdefined(FOG_EXP2) //factor=exp(-(density*z)^2) #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=unity_FogParams.x*(coord);unityFogFactor=exp2(-unityFogFactor*unityFogFactor) #else #defineUNITY_CALC_FOG_FACTOR(coord)floatunityFogFactor=0.0 #endif //【1】若已经定义了FOG_LINEAR、FOG_EXP、FOG_EXP2宏三者之中至少之一,便可以进行到此#if实现部分 #ifdefined(FOG_LINEAR)||defined(FOG_EXP)||defined(FOG_EXP2) //【1-1】定义UNITY_FOG_COORDS(idx)宏 #defineUNITY_FOG_COORDS(idx)floatfogCoord:TEXCOORD##idx; //【1-2】定义UNITY_TRANSFER_FOG(o,outpos)宏 //【1-2-1】若满足着色目标模型的版本小于ShaderModel3.0,或者定义了SHADER_API_MOBILE宏,便可以进行到此#if实现部分 //UNITY_CALC_FOG_FACTOR宏的实现见上 #if(SHADER_TARGET<30)||defined(SHADER_API_MOBILE) //移动平台和ShaderModel2.0:计算每顶点的雾效因子||mobileorSM2.0:calculatefogfactorper-vertex #defineUNITY_TRANSFER_FOG(o,outpos)UNITY_CALC_FOG_FACTOR((outpos).z);o.fogCoord=unityFogFactor //【1-2-2】否则 #else //ShaderModel3.0和PC/游戏主机平台:计算每顶点的雾距离,以及每像素雾效因子||SM3.0andPC/console:calculatefogdistanceper-vertex,andfogfactorper-pixel #defineUNITY_TRANSFER_FOG(o,outpos)o.fogCoord=(outpos).z #endif //【2】否则,直接用UNITY_FOG_COORDS宏计算雾效参数 #else #defineUNITY_FOG_COORDS(idx) #defineUNITY_TRANSFER_FOG(o,outpos) #endif
可以发现,关于此宏的定义,主要集中在如下几句:
[cpp]view plaincopy print? #if(SHADER_TARGET<30)||defined(SHADER_API_MOBILE) //移动平台和ShaderModel2.0:计算每顶点的雾效因子||mobileorSM2.0:calculatefogfactorper-vertex #defineUNITY_TRANSFER_FOG(o,outpos)UNITY_CALC_FOG_FACTOR((outpos).z);o.fogCoord=unityFogFactor //【1-2-2】否则 #else //ShaderModel3.0和PC/游戏主机平台:计算每顶点的雾距离,以及每像素雾效因子||SM3.0andPC/console:calculatefogdistanceper-vertex,andfogfactorper-pixel #defineUNITY_TRANSFER_FOG(o,outpos)o.fogCoord=(outpos).z #endif
而其中宏定义依赖的UNITY_CALC_FOG_FACTOR宏,定义于这段代码的一开头,也根据不同的场合,计算方法分为了几个版本。
OK,顶点着色器分析完篇幅都这么多了,这一节就到这里。
三、屏幕油画特效的实现
之前的文章中提出,Unity中的屏幕特效通常分为两部分来实现:
Shader实现部分脚本实现部分
下面依旧是从这两个方面对本次的特效进行实现。
3.1 Shader实现部分
依旧老规矩,先上注释好的Shader代码。
[cpp]view plaincopy print? //Reference:/view/MsXSRN# Shader"浅墨Shader编程/Volume10/OilPaintEffect" { //------------------------------------【属性值】------------------------------------ Properties { _MainTex("Base(RGB)",2D)="white"{} _Distortion("_Distortion",Range(0.0,1.0))=0.3 _ScreenResolution("_ScreenResolution",Vector)=(0.,0.,0.,0.) _ResolutionValue("_ResolutionValue",Range(0.0,5.0))=1.0 _Radius("_Radius",Range(0.0,5.0))=2.0 } //------------------------------------【唯一的子着色器】------------------------------------ SubShader { //--------------------------------唯一的通道------------------------------- Pass { //设置深度测试模式:渲染所有像素.等同于关闭透明度测试(AlphaTestOff) ZTestAlways //===========开启CG着色器语言编写模块=========== CGPROGRAM //编译指令:指定着色器编译目标为ShaderModel3.0 #pragmatarget3.0 //编译指令:告知编译器顶点和片段着色函数的名称 #pragmavertexvert #pragmafragmentfrag //包含辅助CG头文件 #include"UnityCG.cginc" //外部变量的声明 uniformsampler2D_MainTex; uniformfloat_Distortion; uniformfloat4_ScreenResolution; uniformfloat_ResolutionValue; uniformint_Radius; //顶点输入结构 structvertexInput { float4vertex:POSITION;//顶点位置 float4color:COLOR;//颜色值 float2texcoord:TEXCOORD0;//一级纹理坐标 }; //顶点输出结构 structvertexOutput { half2texcoord:TEXCOORD0;//一级纹理坐标 float4vertex:SV_POSITION;//像素位置 fixed4color:COLOR;//颜色值 }; //--------------------------------【顶点着色函数】----------------------------- //输入:顶点输入结构体 //输出:顶点输出结构体 //--------------------------------------------------------------------------------- vertexOutputvert(vertexInputInput) { //【1】声明一个输出结构对象 vertexOutputOutput; //【2】填充此输出结构 //输出的顶点位置为模型视图投影矩阵乘以顶点位置,也就是将三维空间中的坐标投影到了二维窗口 Output.vertex=mul(UNITY_MATRIX_MVP,Input.vertex); //输出的纹理坐标也就是输入的纹理坐标 Output.texcoord=Input.texcoord; //输出的颜色值也就是输入的颜色值 Output.color=Input.color; //【3】返回此输出结构对象 returnOutput; } //--------------------------------【片段着色函数】----------------------------- //输入:顶点输出结构体 //输出:float4型的颜色值 //--------------------------------------------------------------------------------- float4frag(vertexOutputInput):COLOR { //【1】根据设置的分辨率比值,计算图像尺寸 float2src_size=float2(_ResolutionValue/_ScreenResolution.x,_ResolutionValue/_ScreenResolution.y); //【2】获取坐标值 float2uv=Input.texcoord.xy; //【3】根据半径,计算出n的值 floatn=float((_Radius+1)*(_Radius+1));; //【4】定义一些参数 float3m0=0.0;float3m1=0.0; float3s0=0.0;float3s1=0.0; float3c; //【5】按半径Radius的值,迭代计算m0和s0的值 for(intj=-_Radius;j<=0;++j) { for(inti=-_Radius;i<=0;++i) { c=tex2D(_MainTex,uv+float2(i,j)*src_size).rgb; m0+=c; s0+=c*c; } } //【6】按半径Radius的值,迭代计算m1和s1的值 for(intj=0;j<=_Radius;++j) { for(inti=0;i<=_Radius;++i) { c=tex2D(_MainTex,uv+float2(i,j)*src_size).rgb; m1+=c; s1+=c*c; } } //【7】定义参数,准备计算最终的颜色值 float4finalFragColor=0.; floatmin_sigma2=1e+2; //【8】根据m0和s0,第一次计算finalFragColor的值 m0/=n; s0=abs(s0/n-m0*m0); floatsigma2=s0.r+s0.g+s0.b; if(sigma2<min_sigma2) { min_sigma2=sigma2; finalFragColor=float4(m0,1.0); } //【9】根据m1和s1,第二次计算finalFragColor的值 m1/=n; s1=abs(s1/n-m1*m1); sigma2=s1.r+s1.g+s1.b; if(sigma2<min_sigma2) { min_sigma2=sigma2; finalFragColor=float4(m1,1.0); } //【10】返回最终的颜色值 returnfinalFragColor; } ENDCG } } }
需要注意,本次油画效果的思路来自于Shadertoy中的一个油画效果的实现:/view/MsXSRN#。
此Shadertoy页面贴出的基于GLSL的Shader代码的void mainImage( out vec4 fragColor,in vec2 fragCoord )函数对应于Unity 中Shader的片段着色器。本次Shader中片段着色函数中的实现方法基本由Shadertoy中的这个OilPaint shader优化和精简而来,具体原理应该估计要翻国外的paper来写,会花费不少的时间,精力有限,在这边就暂且不细展开了。暂时只需知道这边就是在片段着色器用类似滤波的操作计算出了不同的颜色值并输出即可。
另外需要注意一点,此Shader的_Radius值越大,此Shader就越耗时,因为_Radius决定了双层循环的次数,而且是指数级的决定关系。_Radius值约小,循环的次数就会越小,从而有更快的运行效率。
3.2 C#脚本实现部分
C#脚本文件的代码几乎可以从之前的几个特效中重用,只用稍微改一点细节就可以。下面也是贴出详细注释的实现此特效的C#脚本:
[csharp]view plaincopy print? usingUnityEngine; usingSystem.Collections; //设置在编辑模式下也执行该脚本 [ExecuteInEditMode] //添加选项到菜单中 [AddComponentMenu("浅墨Shader编程/Volume10/ScreenOilPaintEffect")] publicclassScreenOilPaintEffect:MonoBehaviour { //-------------------变量声明部分------------------- #regionVariables //着色器和材质实例 publicShaderCurShader; privateMaterialCurMaterial; //两个参数值 [Range(0,5),Tooltip("分辨率比例值")] publicfloatResolutionValue=0.9f; [Range(1,30),Tooltip("半径的值,决定了迭代的次数")] publicintRadiusValue=5; //两个用于调节参数的中间变量 publicstaticfloatChangeValue; publicstaticintChangeValue2; #endregion //-------------------------材质的get&set---------------------------- #regionMaterialGetAndSet Materialmaterial { get { if(CurMaterial==null) { CurMaterial=newMaterial(CurShader); CurMaterial.hideFlags=HideFlags.HideAndDontSave; } returnCurMaterial; } } #endregion //-----------------------------------------【Start()函数】--------------------------------------------- //说明:此函数仅在Update函数第一次被调用前被调用 //-------------------------------------------------------------------------------------------------------- voidStart() { //依次赋值 ChangeValue=ResolutionValue; ChangeValue2=RadiusValue; //找到当前的Shader文件 CurShader=Shader.Find("浅墨Shader编程/Volume10/ScreenOilPaintEffect"); //判断当前设备是否支持屏幕特效 if(!SystemInfo.supportsImageEffects) { enabled=false; return; } } //-------------------------------------【OnRenderImage()函数】------------------------------------ //说明:此函数在当完成所有渲染图片后被调用,用来渲染图片后期效果 //-------------------------------------------------------------------------------------------------------- voidOnRenderImage(RenderTexturesourceTexture,RenderTexturedestTexture) { //着色器实例不为空,就进行参数设置 if(CurShader!=null) { //给Shader中的外部变量赋值 material.SetFloat("_ResolutionValue",ResolutionValue); material.SetInt("_Radius",RadiusValue); material.SetVector("_ScreenResolution",newVector4(sourceTexture.width,sourceTexture.height,0.0f,0.0f)); //拷贝源纹理到目标渲染纹理,加上我们的材质效果 Graphics.Blit(sourceTexture,destTexture,material); } //着色器实例为空,直接拷贝屏幕上的效果。此情况下是没有实现屏幕特效的 else { //直接拷贝源纹理到目标渲染纹理 Graphics.Blit(sourceTexture,destTexture); } } //-----------------------------------------【OnValidate()函数】-------------------------------------- //说明:此函数在编辑器中该脚本的某个值发生了改变后被调用 //-------------------------------------------------------------------------------------------------------- voidOnValidate() { //将编辑器中的值赋值回来,确保在编辑器中值的改变立刻让结果生效 ChangeValue=ResolutionValue; ChangeValue2=RadiusValue; } //Updateiscalledonceperframe voidUpdate() { //若程序在运行,进行赋值 if(Application.isPlaying) { //赋值 ResolutionValue=ChangeValue; RadiusValue=ChangeValue2; } //若程序没有在运行,去寻找对应的Shader文件 #ifUNITY_EDITOR if(Application.isPlaying!=true) { CurShader=Shader.Find("浅墨Shader编程/Volume10/ScreenOilPaintEffect"); } #endif } //-----------------------------------------【OnDisable()函数】--------------------------------------- //说明:当对象变为不可用或非激活状态时此函数便被调用 //-------------------------------------------------------------------------------------------------------- voidOnDisable() { if(CurMaterial) { //立即销毁材质实例 DestroyImmediate(CurMaterial); } } }
而根据脚本中参数的设定,就有分辨率和半径两个参数可以自定义条件,如下图:
下面一起看一下运行效果的对比。
四、最终的效果展示
还是那句话,贴几张场景的效果图和使用了屏幕特效后的效果图。在试玩场景时,除了类似CS/CF的FPS游戏控制系统以外,还可以使用键盘上的按键【F】,开启或者屏幕特效。
城镇一隅(with 屏幕油画特效):
城镇一隅(原始图):
城镇路口(with 屏幕油画特效):
城镇路口(原始图):
城镇一隅之二(with 屏幕油画特效):
城镇一隅之二(原始图):
木质城墙和手推车(with 屏幕油画特效):
木质城墙和手推车(原始图):
路边(with 屏幕油画特效):
路边(原始图):
图就贴这些,更多画面大家可以从文章开头下载的本文配套的exe场景,进行试玩,或者在本文附录中贴出的下载链接中下载本文配套的所有游戏资源的unitypackage。
至此,这篇博文已经1万1千多字。感谢大家的捧场。下周浅墨有些事情,所以停更一次,我们下下周,再会。
附: 本博文相关下载链接清单
【百度云】博文示例场景exe下载
【百度云】包含博文示例场景所有资源与源码的unitypackage下载
【Github】屏幕油画特效实现源码