引言
程序化天空盒是我一直想复现的技术,因为自己做的很多场景都是使用固定的skybox,这使得天空整体死气沉沉的,因此决定做一个半卡通风格的程序化昼夜交替天空盒。
效果参考:程序化天空盒实现昼夜变换 - 知乎 (zhihu.com) 以及 Skybox tutorial part 1 | Kelvin van Hoorn ,不过我的各个算法与之有很大出入,所以最终效果和这些教程的差别也比较大。
Part1. 日月模拟
首先我们复现一下日月。在分析日月逻辑之前先创建一个基础Unlit Shader,Shader Type和Light Model都设置成Unlit,而Render Type和Render Queue都设置成Background。然后写入以下预设内容:
Shader "Unlit/mySkybox"
{
SubShader
{
Tags { "RenderType"="Background" "PreviewType"="Skybox" "Queue"="Background"}
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 uv : TEXCOORD0;
};
struct v2f
{
float3 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return distance(i.uv.xyz, _WorldSpaceLightPos0.xyz);
}
ENDCG
}
}
}
前半部分和默认的Unlit Shader无差别,唯独需要注意我们天空盒材质的uv是三维的(这三个维度恰好是视线向量的xyz。这个是cubemap的固有定义),然后在片元着色器我们直接返回一个distance(i.uv.xyz, _WorldSpaceLightPos0.xyz),它计算了天空盒某个方位向量与主光源方向向量的距离(作差),当重合时取到0,当反向时取到最大值:
这里就是我们日月的雏形了,当主光源旋转的时候,黑球的部分就代表了日月所在的位置。现在我们要把黑球区域改变成太阳的样子,如果直接用1-颜色的话,会有一层很柔和的过渡,导致太阳效果不是很好:
这里我们可以用smoothstep函数。smoothstep(a,b,x)函数首先计算了(x-a)/(b-a),再将该结果进行叠乘,其中参数a可以控制过渡的柔和程度,当a很大时就会出现突变的效果:
fixed4 frag (v2f i) : SV_Target
{
half sunDist = distance(i.uv.xyz, _WorldSpaceLightPos0.xyz);
half sunArea = 1 - smoothstep(0.6, 1, sunDist * _SunRadius);
return sunArea;
}
突变效果:
太阳做到这个程度就差不多了,因为它太亮了所以我们无需考虑它的颜色纹理。不过月亮可就没这么简单了,月亮本身亮度很弱所以它的纹理细节非常清晰,所以我们需要考虑采样贴图的问题。
首先我们的月球纹理必须是Cubemap而不能是2D纹理,这个马上会解释为什么。月亮的Cubemap在该链接获取:MoonColorMap.jpg (1024×512) (kelvinvanhoorn.com)
然后我们来分析一下月亮的一个采样方式。我们假设月球是一个球体,它附着在立方体天空盒的边缘处,如下图所示:
该球体的球心坐标<lDir>就是直接光照的方向向量,而管线中枚举到的某个天空盒像素的坐标是<posWS>,这个坐标也代表了视线向量(这两句话如果不懂的话,可以先去学习一下天空盒的渲染原理)。我们可以根据球心位置<lDir>、视线方向<posWS>以及自定义的月球半径来进行一个求交测试,找到该射线与月球体的交点在哪里;然后连接交点和球体中心,就得到了该交点的法线方向。最后,我们使用该法线去采样月球的立方体贴图即可(解释了为什么要用Cubemap类型的月球)
代码如下:
Properties
{
_SunRadius("SunRadius", Range(0.0,10.0)) = 5
[NoScaleOffset] _MoonCubeMap("Moon cube map", Cube) = "black" {}
_MoonRadius("Moon Radius", Range(0, 1)) = 0.05
_MoonMaskRadius("Moon Mask Radius", range(1, 25)) = 10
_MoonSmooth("Moon Smooth", Range(0, 1)) = 0.7
_MoonExposure("Moon Exposure", range(0, 1)) = 1
}
...
float _SunRadius;
samplerCUBE _MoonCubeMap;
float _MoonRadius;
float _MoonMaskRadius;
float _MoonSmooth;
float _MoonExposure;
...
float sphIntersect(float3 rayDir, float3 spherePos, float radius)
{
float3 oc = -spherePos;
float b = dot(oc, rayDir);
float c = dot(oc, oc) - radius * radius;
float h = b * b - c;
if (h < 0.0) return -1.0;
h = sqrt(h);
return -b - h;
}
...
fixed4 frag (v2f i) : SV_Target
{
half sunDist = distance(i.uv.xyz, _WorldSpaceLightPos0.xyz);
half sunArea = 1 - smoothstep(0.7, 1, sunDist * _SunRadius);
float3 posWS = normalize(i.uv.xyz);
float3 lDir = normalize(-_WorldSpaceLightPos0.xyz);
half moonIntersect = sphIntersect(posWS, lDir, _MoonRadius);
half moonDist = distance(i.uv.xyz, -_WorldSpaceLightPos0.xyz);
half moonMask = 1 - smoothstep(_MoonSmooth, 1, moonDist * _MoonMaskRadius);
half3 moonNormal = normalize(lDir - posWS * moonIntersect);
half3 moonTex = texCUBE(_MoonCubeMap, moonNormal).rgb;
half3 moonColor = moonMask * exp2(_MoonExposure) * moonTex;
//return sunArea;
return fixed4(moonColor, 1) + sunArea;
}
其中uv.xyz代表了天空盒上某像素的uv,也代表了它的坐标,更代表了当前视线的方向向量;我们可以额外计算一个mask来糅合月球的边缘,这样有一种韵律感:
冥冥中自有天意……然而这里产生了一个新的问题:真正的月球永远只有一面冲着我们,但在我们的效果中它公转时会把所有面都展示出来了。所以我们需要做一个修正,那就是将采样月球纹理的那些法线全部变换到局部法线空间(即N竖直向上、T水平向右、B水平向外的空间)中,做了这样的变换后所有采样用的法线将永远朝向上半球,那么它就永远只会采样月球纹理的上半区域,这样呈现给我们的区域就永远是同一个面的结果了。
说白了我们就是要将世界空间的法线变换到局部空间法线的过程,方法很简单,直接将世界空间的法线乘以一个TBN矩阵的逆矩阵即可,这样就可以得到“N竖直向上、T水平向右、B水平向外”的局部法线空间下的法线表示了。
首先我们看一下TBN矩阵怎么构建。很容易知道N就是<-lDir>,那么我们可以随便定义一个辅助向量与N叉乘,得到T;再用N叉乘T得到B,这样我们得到了TBN矩阵,再求一下它的逆矩阵即可(PS:TBN矩阵是正交矩阵,因此逆矩阵就是转置矩阵,使用后者的方式开销会小很多)。然后我们拿到之前计算出来的采样法线n,用它乘以TBN逆矩阵,便得到了上半球空间下的n‘,用它去采样月亮纹理。
代码如下:
half3 normalTransform(float3 N, float3 n)
{
N = normalize(N);
float3 T = normalize(cross(N, float3(0.5, 0.5, 0.5)));
float3 B = normalize(cross(N, T));
float3x3 TBN = float3x3(T, B, N);
float3x3 TBN_inverse = transpose(TBN);
return mul(n, TBN_inverse);
}
...
fixed4 frag (v2f i) : SV_Target
{
...
half3 moonNormal = normalize(lDir - posWS * moonIntersect);
moonNormal = normalTransform(-lDir, moonNormal);
...
}
这样月球冲向我们的永远是同一个面了,符合实际的规律。不过目前转向我们的并不是我们想要的面(目前全是白色部分,我们想让冲向我们的面有一半黑色),还需要旋转一下,那么我们可以再定义一个U和一个V来引导创建一个新的TBN矩阵,然后将上述处理过的法线乘以该新TBN矩阵:
_MoonU("Moon U", range(0, 1)) = 0
_MoonV("Moon V", range(0, 1)) = 0
...
half3 normalTransform2(float u, float v, float3 n)
{
float3 N = float3(sqrt(1 - u * u - v * v), u, v);
float3 T = normalize(cross(N, float3(0.5, 0.5, 0.5)));
float3 B = normalize(cross(N, T));
float3x3 TBN = float3x3(N, T, B);
return mul(n, TBN);
}
...
fixed4 frag (v2f i) : SV_Target
{
...
half3 moonNormal = normalize(lDir - posWS * moonIntersect);
moonNormal = normalTransform(-lDir, moonNormal);
moonNormal = normalTransform2(_MoonU, _MoonV, moonNormal);
...
}
然后在面板上调节一下U和V,就可以调整冲向相机的月亮面了。我这里的U=0.645,V=0.34:
Part2. 天空渐变
天空颜色主要分为三部分:天空常色、地平线附近的大气散射、太阳附近的强高光。这里我们直接使用三张现成的贴图:
SunZenith_Gradient.png (128×4) (kelvinvanhoorn.com)
ViewZenith_Gradient.png (128×4) (kelvinvanhoorn.com)
SunView_Gradient.png (128×4) (kelvinvanhoorn.com)
首先是天空常色,基本就是根据太阳光的方向确定当前天空的基础颜色,直接采样贴图即可。
float sunZenithDot01 = (_WorldSpaceLightPos0.y + 1.0) * 0.5;
float3 sunZenithColor = tex2D(_SunZenithGrad, float2(sunZenithDot01, 0.5)).rgb;
其次是地平线附近的大气散射,直接采样贴图即可,但是要知道大气散射只在地平线附近有比较强烈的效果,所以需要做一个mask筛出来地平线的位置:
float viewZenithDot = i.uv.y;
float3 viewZenithColor = tex2D(_ViewZenithGrad, float2(sunZenithDot01, 0.5)).rgb;
float vzMask = pow(saturate(0.9 - viewZenithDot), 5);
最后是太阳附近的强高光,依然采样贴图即可,需要额外做一个mask晒出来太阳附近的区域:
float sunViewDot = dot(_WorldSpaceLightPos0.xyz, i.uv.xyz);
float3 sunViewColor = tex2D(_SunViewGrad, float2(sunZenithDot01, 0.5)).rgb;
float svMask = pow(saturate(sunViewDot - 0.1), 4);
最后将三部分加在一起,并加上太阳和月亮的部分:
float3 skyColor = sunZenithColor + vzMask * viewZenithColor + svMask * sunViewColor;
float3 col = sunColor + moonColor + (1 - sunColor - moonColor) * skyColor;
return fixed4(col, 1);
日出时的大气散射效果:
这里的大气散射并不是基于物理的,而是通过地平线附近的恒定大气色调 与 太阳周围一圈强烈的高光加和产生的,这样也可以大体模拟出日出时橙黄光爆发出来的震撼感。
日出后的整体效果:
这个效果还有待商榷,因为一般上午的时候还是有一点橙色的光辉的,然而这里只剩下蓝色了;除此之外也有很多可以微调的地方,例如:我希望黄昏时的大气散射更强些,而非黄昏时不要有那么强的大气散射,那么我们就可以根据光源方向来动态控制Mask的系数。
Part3. 夜间星空
星空主要有两类主流做法,要么直接用现成的星空HDR,要么使用闪烁的点云图来指引星星的生成。这里我们用前者的方法来偷个懒,下载一个HDR:
SVS - Deep Star Maps 2020 (nasa.gov) 推荐下载8k分辨率的,低于8k的会明显发糊。
具体采样方式就是最朴素的立方体贴图采样,代码如下:
float sunViewDot01 = (sunViewDot + 1.0) * 0.5;
float3 starColor = texCUBE(_StarCubeMap, i.uv.xyz).rgb;
float starStrength = (1 - sunViewDot01) * (saturate(-_WorldSpaceLightPos0.y));
这里的starStrenth用来控制星空强度,根据y值线性变化的话有点不够明显,所以改成了二次比例。最终效果如下:
这里我把星空的力度改的更薄了点,这样显得自然一些。
现在我们分析一下这个结果,优点:有银河,且星星很真实;缺点:一方面是星空贴图太占内存(300MB),另一方面星星没有闪烁效果,缺乏灵动性。有机会可以做一下群星闪烁的效果。
至于星空旋转效果我就不做了,转得太慢没有意义,转得太快看得我头晕。。。
Part4. 静态云效果
云的实现思路非常多,例如raymarching、基础面片云、sdf面片云等等,不过综合效果和性能来看,最好的还是米厂研发的sdf面片云。目前该面片云的素材已经烂大街了,有一个up提供了一份链接,据说这是原神的云素材:
链接:https://pan.baidu.com/s/1wUk_wyJrxjgq_ybYO2KB7A?pwd=437u 提取码:437u
云素材的四个通道代表了四个不同的含义,R通道是云体的明暗部;G通道是边缘高光;B通道是SDF图,表示云在消散时,从哪部分开始消散,哪部分要保留;A通道就是不透明度。
现在我们有了贴图,就需要做一下云的模型。模型直接使用面片云就好了,然后自己手动对一下贴图的uv:
材质这样做:
对于每一个面片都校对一下它的uv,材质直接给那张云的贴图即可。注意alpha通道要互相连接上,这样才有透明效果。(ps:一定要注意面片的法线是不是朝内,如果是朝外的话,放到unity中会直接被“背面剔除”掉)
导出到unity,先创建一个最基础的unlit材质,然后将alpha=0的区域clip掉。代码:
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
clip(col.a-0.1);
return col;
}
能看到下图的效果:
接下来我们将剖分并逐个分析R、G、B三个通道。对于R通道,它表示的是云的明暗面,我们可以预设两个明暗值来控制亮部和暗部的颜色,用lerp来插值;由于亮暗过渡太平滑了,所以我取了个平方:
_CloudLight("Cloud Light", Color) = (1,1,1,1)
_CloudShadow("Cloud Shadow", Color) = (0,0,0,1)
...
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
clip(col.a - 0.9);
fixed3 cloud_color = lerp(_CloudShadow, _CloudLight, 1 - (1 - col.r) * (1 - col.r));
return fixed4(cloud_color, col.a);
}
效果如下:
感觉暗部的颜色还可以再亮一点,毕竟是卡通风格,越接近纯色越有感觉(bushi)
对于G通道,相当于是高光描边的mask。不过我们可不希望云时时都有高光描边,只有在它靠近太阳时描边才行。因此我们可以计算一下云上片元的方向向量和太阳的方向向量的余弦距离,将它作为权重。当然,这意味着我们需要提前在顶点着色器中提前计算出片元的世界坐标并将它传递给片元着色器:
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float4 pos : COLOR;
};
...
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.pos = mul(unity_ObjectToWorld, v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
clip(col.a - 0.5);
fixed3 cloud_color = lerp(_CloudShadow, _CloudLight, 1 - (1 - col.r) * (1 - col.r));
fixed3 edge_spec = col.g;
float edge_spec_weight = pow(saturate(dot(normalize(_WorldSpaceLightPos0.xyz), normalize(i.pos))), 10);
return fixed4(cloud_color + edge_spec * edge_spec_weight, col.a);
}
效果如下:
有了R、G、A三个通道,我们的静态效果就已经完成了。但是现在只要太阳一动,天色一变,我们的云就会显得非常违和,一是因为天色是会影响云的颜色的,如果云一直是一个颜色就显得特别假;二是云的形状完全没有变化会显得很僵硬。那么接下来我们来引入一下动态效果。
Part5. 动态云效果
首先,对于R通道贡献的明暗面,明面是需要跟随天色的色调变化的,而暗面主要跟随天色的亮度变化。所以我们不妨将之前附加给skybox shader的那张天空纹理再传到这里来:
_ViewZenithGrad("View Zenith Grad", 2D) = "black" {}
...
fixed4 frag(v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
clip(col.a - 0.5);
float3 viewZenithColor = tex2D(_ViewZenithGrad, float2((_WorldSpaceLightPos0.y + 1.0) * 0.5, 0.5)).rgb;
float sun_color_weight = saturate(0.3 - abs(_WorldSpaceLightPos0.y));
_CloudLight.xyz = _CloudLight * (1 - sun_color_weight) + viewZenithColor * sun_color_weight;
fixed3 cloud_color = lerp(_CloudShadow, _CloudLight, 1 - pow(1 - col.r, 2));
float sun_light_weight = _WorldSpaceLightPos0.y + 0.7f;
sun_light_weight -= saturate(sun_light_weight - 1);
sun_light_weight += saturate(0.1f - sun_light_weight);
cloud_color *= sun_light_weight;
fixed3 edge_spec = col.g;
edge_spec = edge_spec * (1 - sun_color_weight) + viewZenithColor * sun_color_weight;
float edge_spec_weight = pow(saturate(dot(normalize(_WorldSpaceLightPos0.xyz), normalize(i.pos))), 10);
...
}
这个地方玩的trick比较多,稍微解释一下...首先我们知道在日出或晚霞时,云朵的亮部会倾向于呈现ViewZenithGrad上的橙红色;但是等到中午时,云朵的亮度就不能呈现ViewZenithGrad上的蓝色了,而应该是白色。也就是说,我们应该在日出上下时,对亮部增添ViewZenithGrad颜色,而其余时刻不要增添颜色,直接呈现自身白色即可;
其次,云朵应该随着天色而改变亮度,因此我这里设置了一个权重,以光照的y值+0.7为亮度权重,权重最高为1,最低为0.1。当然这里使用smoothstep可能会更好一些,不过我也懒得调了:
这样日出的时候云朵亮部会被染红;同时云朵的亮度跟随天色变化:
这里问题很明显,夜晚的云朵真的是怎么调都很违和,太亮吧显得好像有日光,太暗吧黑黑的一大坨挂在天上很恶心,所以一般游戏的决策就是直接不允许夜晚出现云朵,一了百了(毕竟消耗了很大性能实现的星空,你这一堆云把星空全遮住了直接血亏啊)……也就是说,我们可以做一个云逐渐消隐和逐渐生长的动画。
这项任务我们可以使用b通道的SDF来完成,我们首先来看一下b通道的样子:
我们可以理解为这是云层的生长/消散图,它描述了云层的哪部分先消散(b值低的区域),哪部分先生长(b值高的区域)。云在生长时,每个点的alpha值等于time + sdf - 1,那么可想而知,对于sdf值为1的点,它们一开始(time=0时)就可以生长出来了;而对于sdf值为0的点,它们只有等到time=1时才可以生长出来。这样我们的云就有了一个从小到大的生长过程,消散过程同理,将上述过程反过来即可。
这里我们将日光的y值作为time:
fixed sdf = col.b;
fixed grow = sdf + _WorldSpaceLightPos0.y;
clip(grow);
grow = 1 - saturate(1 - grow);
return fixed4(cloud_color + edge_spec * edge_spec_weight, col.a * grow);
这样就有了云层逐渐消隐的感觉:
事实上使用sdf的魅力不仅在于可以描述云的生长过程,而且还可以凸显出一块云内部的不同厚度(每个点的alpha值不一样,通透感就不一样),这样一下子就有了很强烈的层次感:
可以看到云层有的厚有点浅,有的沉重有的迷离,这样的氛围就非常微妙了:
好了,有了sdf后我们的云的动态感就很强了。不过最后还有一个可以增强动态感的点,那就是边缘高光。目前我们的边缘高光描得太实了,如果我们能让边缘部分有一种流动消隐的感觉,那云的动态感就非常强了。这里我们可以直接使用一张噪声图和系统时间变量来描述云边缘的流动消隐:
fixed4 col = tex2D(_MainTex,i.uv);
fixed noise_weight = col.g;
fixed noise1 = tex2D(_NoiseTex, i.uv * 12.0f + _Time.y / 16);
fixed noise2 = tex2D(_NoiseTex, -i.uv * 12.0f + _Time.y / 16);
col = tex2D(_MainTex, i.uv + noise_weight * (fixed2(noise1, noise2) * 2 - 1) / 50);
最开始我们照常采样,然后我们检查g通道的值来确定当前点是否为边缘处,如果是的话,我们将通过_Time采样噪波纹理作为实时扰动的值,然后再借该扰动去重新采样贴图,这样边缘就可以呈现出扰动感了。
最后,我们通过C#脚本来让云层有个围绕中心旋转的行为:
void Update()
{
float dir = (kind % 2) * 2 - 1;
transform.Rotate(Vector3.up * 0.001f * dir, Space.Self);
}
kind是云层的代号,奇数和偶数的旋转方向是相反的。这样,我们白天的动态云效果就完成了。
最后还是想提一下夜间云的事,我们其实不能二极管地说夜间一定没有云,只是夜间的云不是像白天那样清晰成型。我们可以使用噪声纹理来形成一层非常轻薄的云层,然后让他沿着固定方向流动;同时这层云会轻微遮盖住星空上的星星。这里我xjb试 试出来一个好办法,那就是直接操纵星星的权重starStrength:
//星空
float3 starColor = texCUBE(_StarCubeMap, i.uv.xyz).rgb;
float starStrength = (1 - sunViewDot01) * (saturate(-_WorldSpaceLightPos0.y));
fixed noise = tex2D(_NoiseTex, i.uv.xz * 1.0f + _Time.y / 32).r;
noise *= noise;
noise = saturate(noise - 0.3);
//return noise;
starStrength *= noise;
之前我们星星的权重是处处相等的,它由日光方向控制。这次我们在这上面做了点文章,通过采样噪声图来降低星星的权重,表示这部分被云雾所遮挡。这样我们可以获得以下效果:
可以看到有一层很薄的云层在隐约地流动着,并且遮挡住了原本闪亮的星星。下面放一张没有云层的图作为对比:
这样最基础的程序化天空盒效果就完成了,放一张预览结果吧:
云放的有点低了- -!不过问题不大,到时候在blender中重新摆一下面片吧。
Part6. 基础水面反射
“落霞与孤鹜齐飞,秋水共长天一色”,这是我最喜欢的诗句之一。只有水面才最能衬托出天空的壮观!所以接下来我们做一下水面效果。首先创建一个unity中自带的Plane模型,然后创建一个水面的专属shader。
我们分析一下水面的要素。首先:反射,这是毋庸置疑的,我们希望它尽可能地照射出天空的样子;其次需要有水面波纹,需要一张或若干张法线贴图来描述;最后就是菲涅尔效应,尽量做到远海处反射更强、近海处透射更强的效果(不过这里我们也做不了透射,毕竟海下面也没有陆地,所以这里的透射会被平替成漫反射,后面会详细介绍这部分)。
第一步,我们通过unity自带的反射探针来捕捉世界图像。在unity的场景层次栏中右键-Light-Reflection Probe,这样我们就在世界空间中建立了一个反射探针,注意我们最好将他放到和相机重合的位置,这样捕捉出来的图像才是与相机视角吻合的。然后我们将它的Type属性改成Realtime,Refresh Mode设置为Every frame。之所以要设置成实时的,是因为我们的天空盒每一帧都在改变颜色,所以不能提前烘焙一张供全程使用。
此时我们应该能够从右下角看到其捕捉的全景图像了。不过现在这张全景图还在探针里没有写出,我们需要将这张图像拿到本地,再将其传进shader,所以需要先在资产区创建一个CustomRenderTexture用来接收全景图像,将它的Dimension设置成Cube,然后定义一个C#脚本来控制每帧刷新渲染并将图像传送到CustomRenderTexture中:
public class SkyboxBaker : MonoBehaviour
{
ReflectionProbe rp;
public CustomRenderTexture crt;
void Start()
{
rp = GetComponent<ReflectionProbe>();
GameObject.Find("Plane").GetComponent<MeshRenderer>().material.SetTexture("_ReflectionMap", crt);
}
// Update is called once per frame
void Update()
{
rp.RenderProbe(crt);
}
}
我们直接将这个CustomRenderTexture绑定到shader中的_ReflectionMap参数中,然后在Update函数中每帧将渲染结果写入到CustomRenderTexture中即可。
接下来我们来写一下水的shader,我们只需要额外定义一下_ReflectionMap这个全景图,然后在片元着色器中用视角反射向量去采样这张全景图就好了。shader代码如下:
_ReflectionMap("Reflection Map", Cube) = "black" {}
...
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 normal : NORMAL;
float3 pos : TEXCOORD1;
};
...
fixed4 frag(v2f i) : SV_Target
{
fixed3 viewDir = normalize(i.pos - _WorldSpaceCameraPos);
fixed3 reflectDir = reflect(viewDir, i.normal);
fixed3 reflectColor = texCUBE(_ReflectionMap, normalize(reflectDir));
return fixed4(reflectColor, 1);
}
这部分内容非常简单就不多解释了,然后我们点击运行来看下效果:
当我们发现天空和“水面”的颜色基本对称,并且能达到“水天一线”的效果是最好的。不过我们这里还是能在侧面发现比较明显的水天分界线,这是因为相机与海最远处的点形成的向量还是不够平,以至于形成了一定的反射角,而这个反射角就会捕捉更靠上的天空信息,就会在这里形成一点断裂感。我们可以通过操纵采样uv值,将uv的y值减小来消除这种断裂感:
fixed3 viewDir = normalize(i.pos - _WorldSpaceCameraPos);
fixed3 reflectDir = reflect(viewDir, i.normal);
fixed3 reflectColor = texCUBE(_ReflectionMap, normalize(reflectDir) - fixed3(0, 0.05, 0));
return fixed4(reflectColor, 1);
修正后的效果如下:
这样就可以消除分界线了。不过我发现这样做以后在其他时间点又会出现小bug...只能说这个trick还是得谨慎使用。
最后要说的是...你的海一定要够大,要非常大,大到你的相机不可能望到尽头最好,不然很快就会穿帮!
Part7. 水面波纹
水面波纹我们直接用法线图来糊一下就好了。首先我拿到了ue5里初学者包中自带的水面法线贴图:
这样的法线贴图网上有不少,可以下载一些。我们先把它贴上去看看吧,shader代码如下:
fixed4 frag(v2f i) : SV_Target
{
fixed3 viewDir = normalize(i.pos - _WorldSpaceCameraPos);
fixed3 normal = normalize(UnpackNormal(tex2D(_NormalTex, i.uv)));
fixed tmp = normal.b;
normal.b = normal.g;
normal.g = tmp;
fixed3 reflectDir = normalize(reflect(viewDir, normal));
fixed3 reflectColor = texCUBE(_ReflectionMap, normalize(reflectDir - fixed3(0, 0.00, 0)));
return fixed4(reflectColor, 1);
}
要注意采样法线贴图之后需要unpack,同时需要交换g、b通道,因为法线贴图默认b通道是竖直向上方向的,而unity中y轴是竖直向上的。效果如下:
接下里我们的任务是让水面流动起来,具体方式就是使用时间参数来扰动采样uv。不过只有一层扰动是不够的(这样水面的运动太有规律了),一般来说最少需要两层扰动。一个最简单的方法就是使用同一张法线贴图,不同的采样器(即通过控制Time形成不同的uv scale和offset)
这里我的采样方式是这样的:
float3 GetBaseNormal(float2 f_texcoord)
{
float2 texcoord = f_texcoord * float2(1, 1) + _Time.y * float2(-0.03, -0.02);
float3 normal = UnpackNormal(tex2D(_NormalTex, texcoord));
return normal;
}
float3 GetAdditionNormal(float2 f_texcoord)
{
float2 texcoord = f_texcoord * float2(-2, 2) + _Time.y * float2(-0.08, -0.08);
float3 normal = UnpackNormal(tex2D(_NormalTex, texcoord));
return normal;
}
float3 BlendAngleCorrectedNormals(float3 n1, float3 n2)
{
return normalize(float3(n1.xy + n2.xy, n1.z));
}
fixed4 frag(v2f i) : SV_Target
{
fixed3 viewDir = normalize(i.pos - _WorldSpaceCameraPos);
fixed3 normal1 = GetBaseNormal(i.uv);
fixed3 normal2 = GetAdditionNormal(i.uv);
fixed3 normal = BlendAngleCorrectedNormals(normal1, normal2);
fixed tmp = normal.b;
normal.b = normal.g;
normal.g = tmp;
fixed3 reflectDir = normalize(reflect(viewDir, normal));
fixed3 reflectColor = texCUBE(_ReflectionMap, normalize(reflectDir - fixed3(0, 0.00, 0)));
return fixed4(reflectColor, 1);
}
这里先定义了两个函数,分别是GetBaseNormal和GetAdditionNormal,这两个就是用来生成扰动法线的函数,区别在于两者的扰动速率和幅度是不一样的,BaseNormal一般波会大一点、流深慢一点,而AdditionNormal就是那种流速快的小波。
得到两个法线后,我们要将他们做叠加。法线叠加并非直接三个通道相加取平均,这里我们使用unity的法线混合方式,就是将两个法线的切线、副切线方向值相加,法线方向直接保留第一个法线的值,然后再归一化。具体原理不知道是啥,但普遍表示这个法线混合方式很好。效果如下:
这样水就流动起来了,并且大概能看到分散的反射效果。不过现在水的颜色太过诡异了,有点像被打碎的镜子,所以接下来我们准备对水面进行着色计算。
Part8. 水面着色
理想状态下可以用blinn-phong模型来拟合水面,但是效果不咋地,所以做了一点trick。大致思路就是将水面颜色分为漫反射和镜面反射两部分,比例由菲涅尔项来控制;对于漫反射项,直接取天空的主色调作为漫反射颜色,再乘以一个光线与法线的cosine项即可;对于高光分量我试错了很久,最后决定抛弃半程向量与法线的cosine值,直接用反射出来的颜色值作为高光结果就得了,这样能保证水天交融的区域不会出现分界线。
fixed4 frag(v2f i) : SV_Target
{
fixed3 viewDir = normalize(i.pos - _WorldSpaceCameraPos);
fixed3 normal1 = GetBaseNormal(i.uv);
fixed3 normal2 = GetAdditionNormal(i.uv);
fixed3 normal = BlendAngleCorrectedNormals(normal1, normal2);
fixed tmp = normal.b;
normal.b = normal.g;
normal.g = tmp;
fixed3 reflectDir = normalize(reflect(viewDir, normal));
fixed3 lightDir = _WorldSpaceLightPos0.xyz;
//fixed3 H = normalize(lightDir - viewDir);
//fixed cosine_HN = saturate(dot(H, normal)) + 0.3;
//cosine_HN = 1 - saturate(1 - cosine_HN);
//return fixed4(cosine_HN, cosine_HN, cosine_HN, 1);
//fixed weight = 1.1 + viewDir.y;
//weight *= weight;
float3 reflectColor = texCUBE(_ReflectionMap, normalize(reflectDir));
float sunZenithDot01 = (_WorldSpaceLightPos0.y + 1.0) * 0.5;
fixed cosine_LN = saturate(dot(_WorldSpaceLightPos0.xyz, i.normal)) + 0.8;
cosine_LN = 1 - saturate(1 - cosine_LN);
float3 diffuseColor = tex2D(_SunZenithGrad, float2(sunZenithDot01, 0.5)).rgb * cosine_LN;
float F = fresnelSchlick( saturate(dot(-viewDir, i.normal)), 0.0f) + 0.15f;
F = 1 - saturate(1 - F);
//F *= 1 - 0.5 * abs(lightDir.y);
fixed3 col = reflectColor * F + diffuseColor * (1 - F);
return fixed4(col, 1);
}
效果如下,日出:
日落:
月夜:
哎只能说没有顶点动画的水还是有些死板,不过大概也满足需求了。这个水的高光部分也是挺难调的,如果暗了吧,就会在水天交融的地方出现丑陋的分界线;如果亮了吧,又显得很晃眼,导致失真。。。接下来就是慢慢的磨参数了,也不知道磨到什么程度才算完美。。。
总结
效果大概达到了及格线,不过还没有达到我心中的优秀线,估计需要继续大量地调参才能实现更好的效果。
事实上还有很多可做的内容,例如lens flare(奈何我unity版本太低不支持这个插件)、雨雾天气、辉光效果等等,不过这些都需要根据具体应用场景去加,所以本篇blog就不浪费口舌了。
Comments | 1 条评论
博主 xuxk
这shader真不错,很适合学习