作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记19:噪声纹理
原文发布时间:2020年3月31日
文章类型: 授权转载
Till the roof comes off
Till the lights go out
Till my leg give out
Cant shut my mouth
——《Till I Collapse》(电影铁甲钢拳配乐)
如果我们要做特效的开发,随机数是一个非常重要的概念。但是可惜的是,GPU是很难生成随机数的,为了能够产生随机数,可以使用一张随机数纹理来作为随机数的查找表。这样产生一个伪随机的效果。在随机数的加持下,我们可以做出更多好看的效果。
消融效果(dissolve),经常用于一些游戏中的怪物死亡,或者建筑烧毁然后消失的情景。消融从随机的地方开始,然后向四周蔓延,直到所有的地方都消失不见,然后被系统删除。另外,消融效果应该是那种看起来非常高大上,但其实很好做的效果之一。我们用对噪声纹理采样的结果和一个指定的阈值作对比,如果小于阈值或者大于阈值,就把这个像素剔除掉。这些部分就对应了被烧毁的区域,阈值应该是从0到1,当阈值为0的时候,物体是完好无损的,当阈值为1的时候,物体完全消失不见。
首先准备一张噪声纹理,什么?你没有?当然是用PS来制作一张啦,点一下玩一年,贴图不花一分钱。
打开PhotoShop2019CC,新建一张512*512大小的纸张。然后改变选色区的两种颜色。
接着选择滤镜->渲染->云彩,你会发现,呜哇,它居然成了下面这样。
另外,这张图不是固定的哦,是随机生成的,也就是你再点一下,它就变的不一样了。保存到本地,然后导入到Unity里面,就可以开始写代码了。
Shader "UShaderMagicBook/Dissolve"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_NoiseTex("Noise Texture",2D) = "white"{}
_Threshold("Disappear Threshold",Range(0,1)) = 0
_BurnFirstColor("Burn First Color",Color) = (1.0,1.0,1.0,1.0)
_BurnSecondColor("Burn Second Color",Color) = (1.0,1.0,1.0,1.0)
_LineWidth("Line Width",Float) = 1
_BumpMap("Bump Map",2D) = "bump"{}
_BumpScale("Bump Scale",Range(-5,5)) = 1
}
SubShader{
pass{
Tags{"LightMode"="ForwardBase"}
Cull Off
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 worldPos : TEXCOORD2;
SHADOW_COORDS(3)
};
sampler2D _MainTex;
sampler2D _NoiseTex;
fixed _Threshold;
float _LineWidth;
fixed4 _BurnFirstColor;
fixed4 _BurnSecondColor;
sampler2D _BumpMap;
float _BumpScale;
vertexOutput Vertex(appdata_tan v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
TANGENT_SPACE_ROTATION;
//TANGENT_SPACE_ROTATION指的就是计算切线,副切线,法线等一大堆操作。
//由于它是一个宏,它会声明一个rotation来计算,所以我们这里引用rotation即可
//rotation就是切线空间转换矩阵
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
TRANSFER_SHADOW(o)
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed4 albedo = tex2D(_MainTex,i.uv);
fixed noise = tex2D(_NoiseTex,i.uv).r;
fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap,i.uv)) * _BumpScale;
//先对所有的纹理进行采样
fixed3 tangentLightDir = normalize(i.lightDir);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(tangentNormal,tangentLightDir));
fixed t = 1 - smoothstep(0,_LineWidth,noise - _Threshold);
fixed3 burnColor = lerp(_BurnFirstColor,_BurnSecondColor,t);
burnColor = pow(burnColor,5);
//计算灼烧的边缘的颜色
clip(noise - _Threshold);
//剔除一部分像素
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
return fixed4(lerp(ambient + diffuse * atten,burnColor,t * step(0.0001,_Threshold)),1.0);
}
ENDCG
}
}
}
这里的难点在于,在镂空(已经被剔除的部分)到还没有被剔除的部分之间,要做一个灼烧颜色的过渡。这个实现起来还是稍微有点麻烦的,为了理解是怎么实现的,我们先了解消融的过程。
我们在一个实数轴上选取了0到1的部分,表示一个模型当前的消融程度。
这样,我们的阈值就可以表示消融程度,我们注意到没有被消融掉的部分,它的意义是什么呢?其实就是我们采样到的噪声纹理值是减去阈值是大于0的。如下所示
noise - threshold > 0
不过虽然都是大于0,这些值肯定有大有小,比如有的是0.01,有的是0.5,它们虽然都还没有消融,但是本质上,如果这些差值(噪声纹理与阈值的差值)过小,就表示它们已经非常靠近那些已经被消融的部分了。(也就是说在边缘部分)
ok,检测一个值是否落在一个边缘是很容易的。我们可以通过smoothstep函数来实现
fixed b = smoothstep(0,beta,noise - threshold);
//这里的beta是我们设定的范围,如果noise-threshold落在了[0,beta]内
//我们就用它作为一个权重,在0和beta之间进行插值
这里的beta是我们设定的范围,如果noise-threshold落在了[0,beta]内,我们就用它作为一个权重,在0和beta之间进行插值。得到的结果主要是用于让剩下的没有被消融的部分,和即将要消融的部分,进行一个插值计算。
即将消融的部分就是被灼烧的颜色,这个颜色需要我们自己来设定,然后用这个颜色和还没有被消融的部分进行插值计算。如下图所示
由于主要的部分仍然是主纹理,灼烧部分只是边缘,所以我们计算出的t还要反相。
由于被反相了,所有t其实是一个靠近1的值,当我们将它乘上_Threshold的时候,它可以近似的表示为_Threshold,所带来的效果就是,当_Threshold越大的时候,灼烧的效果与主纹理之间的过渡就越偏向于灼烧效果。
这部分其实有点怪怪的,虽然理是这个理,但是我建议大家将里面的很多参数修改一下,来尝试不同的效果,比如最后一行我们可以不乘_Threshold试试看。当然啦,这些就由大家自己尝试了。我们还是放一下上面代码所产生的最终效果~
另外,我总觉得我说的不是很好,可能将大家带入了一些理解误区。为了以防万一,我们这里放一下原书的的讲解。原书第300页
然后嘞,由于我们灼烧了这个模型,它的阴影不可能不变。所以我们需要自己编写一个ShadowCaster,把阴影映射纹理也剔除掉。在上面代码的基础上,我们增加一个shadow pass即可。
pass{
// this pass is a shadow map pass
Tags{"LightMode"="ShadowCaster"}
CGPROGRAM
#pragma vertex VertexShadow
#pragma fragment PixelShadow
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
struct vertexOutput_shadow{
V2F_SHADOW_CASTER;
float2 uv : TEXCOORD0;
};
sampler2D _NoiseTex;
fixed _Threshold;
vertexOutput_shadow VertexShadow(appdata_base v){
vertexOutput_shadow o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
o.uv = v.texcoord;
return o;
}
float4 PixelShadow(vertexOutput_shadow i):SV_TARGET{
fixed noise = tex2D(_NoiseTex,i.uv).r;
clip(noise - _Threshold);
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}
它可真是漂亮呢~
注意,这里的水波效果只是视觉效果上的水波,准确来说,是有光照,但是没有顶点变化。(也就是在一张不会动的纸上,用纯粹的光照计算来表达出水波)当我们从水平面观察这个水面的时候,它就露馅了。而且正式的水面计算中,也不太需要这样的技术。
不过还是那句话,我们所学的内容,都是非常低级和简单的。我们学的是规则和算法,重点是算法,才不会在乎用什么来编写,比如在UE中用CG或者蓝图来编写着色器,在Godot中用GLSL来编写。在Unity的URP中用ShaderGraph来编写着色器,规则大相径庭的,重点是算法。
我们这里会使用噪声纹理来充当一个高度图,用它来表示水面的法线。我们会使用到时间变量和这个高度图,两者混合在一起。当法线随时间进行变化。得到法线之后,再根据法线来进行正常的光照计算。
我们之前解决过一个超级麻烦的问题,即使用GrabPass来模拟出玻璃的透射效果,另外利用Cubemap模拟出反射效果。这两个技术这里都是要用到的,除了这两点,我们还会增加水面的动态,可以说是相对来说比较复杂的Shader。
首先我们先回忆一下菲涅尔反射公式
�������=���(1−���(0,�⋅�),4)
其中v*n表示视角方向与法线的夹角,这个数字越小,菲涅尔系数越小,则反射越弱,折射越强。ok,了解这个之后我们就可以开始写代码了,不过由于我们需要构建一个Cubemap,所以我们先用方块构建一个简单的场景。
我用了几个5个方块拼出了一个池子一样的东西,然后把这五个方块都放入一个GameObject,这样可以把它存为一个prefab。
我们在里面放一个quad,然后用我们在之前的Cubemap章节中所编写的Cubemap Generator来生成这个quad周围的景象。
生成的结果如下图所示
然后我们就可以开始写代码了。
Shader "UShaderMagicBook/fakeWater"{
Properties{
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_MainTex("Main Texture",2D) = "white"{}
_NoiseTex("Noise Texture",2D) = "white"{}
_Cubemap("Environment Cubemap",Cube) = "_Skybox"{}
_WaveXSpeed("Wave Horizontal Speed",Range(-0.1,0.1)) = 0.01
_WaveYSpeed("Wave Vertical Speed",Range(-0.1,0.1)) = 0.01
_Distortion("Distortion",Range(0,100)) = 10
/*_BaseColor表示的是水面的颜色,而_MainTex表示的是水面的主纹理,这个主纹理可有可无。
_NoiseTex本质是一个噪声纹理,这里用于充当法线纹理。而_Cubemap就是反射纹理了。
XSpeed和YSpeed表示的都是发现在x和y方向上的平移速度,至于_Distortion表示的是透射的程度*/
}
SubShader{
Tags{"Queue"="Transparent" "RenderType"="Opaque"}
//由于水面是透明的所以这里给一个Transparent,表示它在不透明的物体之后绘制。
GrabPass{"_RefractionTex"}
//用GrabPass抓取屏幕内容,塞到纹理中
pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdbase
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 screenPos : TEXCOORD1;
float4 TW1 :TEXCOORD2;
float4 TW2 :TEXCOORD3;
float4 TW3 :TEXCOORD4;
};
fixed4 _BaseColor;
sampler2D _MainTex;
sampler2D _NoiseTex;
samplerCUBE _Cubemap;
fixed _WaveXSpeed;
fixed _WaveYSpeed;
float _Distortion;
sampler2D _RefractionTex;
float4 _RefractionTex_TexelSize;
vertexOutput Vertex(appdata_tan v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeGrabScreenPos(o.pos);
o.uv = v.texcoord;
float3 worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal,worldTangent) * v.tangent.w;
o.TW1 = float4(worldTangent.x,worldBinormal.x,worldNormal.x,worldPos.x);
o.TW2 = float4(worldTangent.y,worldBinormal.y,worldNormal.y,worldPos.y);
o.TW3 = float4(worldTangent.z,worldBinormal.z,worldNormal.z,worldPos.z);
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
float3 worldPos = float3(i.TW1.z,i.TW2.z,i.TW3.z);
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float2 speed = _Time.y * float2(_WaveXSpeed,_WaveYSpeed);
fixed3 bump1 = UnpackNormal(tex2D(_NoiseTex,i.uv + speed)).xyz;
fixed3 bump2 = UnpackNormal(tex2D(_NoiseTex,i.uv - speed)).xyz;
fixed3 bump = normalize(bump1 + bump2);
//计算法线纹理,这里的法线会随着时间而产生偏移
float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;
i.screenPos.xy = offset * i.screenPos.z + i.screenPos.xy;
fixed3 refractColor = tex2D(_RefractionTex,i.screenPos.xy/i.screenPos.w).xyz;
//计算透射部分,也就是扭曲模型背后的部分
bump = normalize(half3(dot(i.TW1.xyz,bump),dot(i.TW2.xyz,bump),dot(i.TW3.xyz,bump)));
//将切线空间的bump转换到世界空间
fixed4 texColor = tex2D(_MainTex,i.uv + speed);
fixed3 relfDir = reflect(-viewDir,bump);
fixed3 reflCol = texCUBE(_Cubemap,relfDir).xyz * texColor.xyz * _BaseColor.xyz;
//计算反射角度,根据反射角度来在Cubemap中选择反射颜色。
fixed fresnel = pow(1 - saturate(dot(viewDir,bump)),4);
fixed3 finalColor = reflCol * fresnel + refractColor * (1 - fresnel);
return fixed4(finalColor,1);
}
ENDCG
}
}
}
这里就是完整的代码,相对于玻璃特效来说,它只是在对纹理进行采样的时候增加了时间元素,其他的几乎不变。不过这个特效最糟糕的地方在于,它没有计算任何光照,而是单纯的通过反射来反应水面的特点。因而其实是比较糟糕的一种水面实现手段。这里并不推荐大家使用,学习一下就可以了。
关于噪声纹理,还有很多地方可用,我们略过了这个章节的最后的部分,即全局雾效,因为那个是后期处理,而我们直接略过了12和13章,所以没有那个基础,这个部分是讲不了的。但是没关系,因为我们也并不是要学什么内容,真正的以内容为核心的学习不在这个系列中展现,谢谢大家的持续关注。
另外,本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎关注给我的github贡献一颗⭐,感谢阅读。