找回密码
 立即注册
查看: 1755|回复: 20

[笔记] Unity Shader 水体渲染

[复制链接]
发表于 2021-4-28 09:42 | 显示全部楼层 |阅读模式
Unity Shader Water 真实感水体的制作心得


用了两周半的时间研究了一下基本的水体渲染。效果中并不涉及顶点的波移动,仅为法线贴图扰动,其中包括了海浪、平面反射、高光反射、折射等效果。适合用于PC端各类型游戏的水面效果,且源代码用的是最基本的顶点片元着色器和一些常见的shaderlab函数,也容易运用到其他引擎上。
此文是我一个学习笔记,分享出来希望大家一起学习,如果有大佬教导就更好了。
我在看了网上能找到的多部分水体的文章和论文后(文末会列出),结合一些实际游戏的效果图,其中代码会有所ctrlc/v和删减,实际效果还有许多欠缺,之后会继续学习。
首先要说我选择的是在世界空间下计算这些参数,即先在顶点着色器中计算切线空间到世界空间的变换矩阵,把他传递给片元着色器,再在片元着色器中把法线方向从切线空间变换到世界空间。 在计算中我会运用到这些基础向量:worldPos / lightDir / viewDir  / halfDir / NdotL(法线点乘光源方向)/  NdotH(法线点乘半角向量)均为世界空间下 。
关于世界空间下法线的变换 详请翻阅《入门精要》P152
这里贴上主要代码:
  1. struct v2f
  2. {
  3. float4 pos : SV_POSITION;
  4. float2 uv : TEXCOORD0;  
  5. float4 TtoW0:TEXCOORD2;
  6. float4 TtoW1:TEXCOORD3;
  7. float4 TtoW2:TEXCOORD4;
  8.   };
复制代码
//顶点着色器的输出结构体v2f,包含了切线空间到世界空间的变换矩阵。
  1. v2f vert (appdata v)
  2. {
  3. v2f o;
  4. o.pos = UnityObjectToClipPos(v.vertex);
  5. o.uv = TRANSFORM_TEX(v.uv, _MainTex);
  6. float3 worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
  7. float3 worldNormal = normalize(UnityObjectToWorldNormal(v.normal));
  8. fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);  
  9. fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
  10. o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
  11. o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
  12. o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
  13. return o;
  14. }
复制代码
顶点着色器中计算了世界空间下的顶点切线、副法线和法线的矢量表示。
//要用的向量
  1. float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
  2. float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
  3. float3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
  4. float3 halfDir = normalize(lightDir + viewDir);
  5. fixed3 tangentNormal1 = UnpackNormal(tex2D(_NormalTex , i.uv  + offset)).rgb;
  6. fixed3 tangentNormal2 = UnpackNormal(tex2D(_NormalTex , i.uv  - offset)).rgb;
  7. fixed3 tangentNormal = normalize(tangentNormal1 + tangentNormal2);
  8. tangentNormal.xy *= _NormalScale;
  9. tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
  10. float3 worldNormal = normalize(half3(dot(i.TtoW0.xyz, tangentNormal), dot(i.TtoW1.xyz, tangentNormal), dot(i.TtoW2.xyz, tangentNormal)));
  11. float NdotH = max(0,dot(halfDir , worldNormal));  //BlinnPhong
  12. float NdotL = max(0,dot(worldNormal , lightDir)); // 漫反射
复制代码
这里的offset是一个法线扰动值,之后会讲解到。计算LightDir / viewDir 使用了UnityCG.cgine中常用的帮助函数,了解请翻阅《入门精要》P108,详细请查找源码 。使用前需要在前面声明:
  1. #include "UnityCG.cginc"
复制代码
我们现在基本的向量都计算完了,有了这些向量我们就可以开始逐个计算各个元素了。我们先计算了A(ambient)D(diffuse)S(specular)
  1. fixed3 diffuse = _LightColor0.rgb*col*saturate(dot(worldNormal , lightDir)) ;
  2. fixed3 specular = pow( NdotH , _Specular * 128.0) * _Gloss;
  3. float3 ambient = col*UNITY_LIGHTMODEL_AMBIENT.xyz;
复制代码
(_LightColor0是directional light的颜色)
使用_LightColor0 和UNITY_LIGHTMODEL_AMBIENT需要在前面声明
  1. #include "Lighting.cginc"
复制代码
这是最基本的光照模型,我们可以看到以下效果。
///
之后我们加入法线偏移,这样水面就会动起来,原理是用一张法线贴图根据内置的_Time函数计算出一个可动的float2类型的偏移值,再在法线从切线空间转到世界空间的时候进行uv偏移,达到水面流动的效果。
//法线扰动
  1. float4 offsetColor = (tex2D(_NormalTex, i.uv
  2.                 + float2(_WaveXSpeed*_Time.x,0))
  3.                 + tex2D(_NormalTex, float2(i.uv.y,i.uv.x)
  4.                 + float2(_WaveYSpeed*_Time.x,0)))/2;
  5. //   float4 waveOffset = tex2D(_NormalTex ,i.uv +  wave_offset);
  6. half2 offset = UnpackNormal(offsetColor).xy * _NormalRefract;//法线偏移程度可控之后offset被用于这里
  7. fixed3 tangentNormal1 = UnpackNormal(tex2D(_NormalTex , i.uv  + offset)).rgb;
  8. fixed3 tangentNormal2 = UnpackNormal(tex2D(_NormalTex , i.uv  - offset)).rgb;
复制代码
这样水面就动起来了。
///
关于岸边效果,我这里更改一下看了很多遍后的概念(这里的代码和源码比有所更改)
    岸边效果的本质就是在视角空间下比较水面地面的线性深度,水面是半透物体,地面时不透物体。所以本质就是比较视角空间下半透和不透物体的线性深度差值。半透物体的深度值 是NDC空间下的z值,ComputeScreenPos返回的是齐次裁剪空间下的屏幕坐标,除以w等于NDC空间下的屏幕坐标。
  1. float4 clipPos = UnityObjectToClipPos(v.vertex);
  2. float4 screenPos = ComputeScreenPos(clipPos);
  3. float4 screenPosNDC = screenPos / screenPos.w; //NDC下的屏幕坐标
复制代码
    不透物体的深度值 是通过直接获得unity相机内置的屏幕深度 CameraDepthTexture得到的,有四种方法:tex2Dproj的作用就是程序自动进行透视除法,效率比写出来的除要高,而这里我选择depth_2作为输出,因为他也是采样的NDC的xy坐标,和上文获取到的深度值在一个坐标空间里,所以理解起来比较方便。
  1. //详见 https://zhuanlan.zhihu.com/p/107627483
  2. float depth = tex2Dproj (_CameraDepthTexture,screenPos).r;    //UNITY_PROJ_COORD:深度值 [0,1]
  3. float depth_1 = tex2D(_CameraDepthTexture,screenPos.xy / screenPos.w).r;  //UNITY_PROJ_COORD:深度值 [0,1]
  4. float depth_2 = tex2D(_CameraDepthTexture,screenPosNDC.xy).r;  //直接采样NDC下的xy坐标
  5. float depth_3 = SAMPLE_DEPTH_TEXTURE( _CameraDepthTexture, screenPosNDC.xy ); //SAMPLE_DEPTH_TEXTURE解决平台差异性问题
复制代码
    之后我们有了水面和地面的非线性深度,都是基于NDC坐标空间的。现在就是要把他们变为视角空间,用的是 LinearEyeDepth。得到不透物体和半透物体的线性深度值,然后做差。
  1. half s_depth = LinearEyeDepth(depth_2);   //不透明物体的线性深度
  2. half t_depth =  LinearEyeDepth(screenPosNDC.z); //半透明物体的线性深度
复制代码
    注:2021.3.17改,在看了大量文章后,有点明白了。但是目前这样的描述不知道是否准确,望指正。因为我看还有一种方法,就是关于ComputeScreenPos函数返回的值,我们知道这个得到的值其实不是字面意思齐次空间下屏幕的坐标值,他是个float4类型的值,根据输入值clipPos 我们知道他的输入值是裁剪空间下的顶点坐标,之后看过ComputeScreenPos的源码后发现计算完的zw值和输入的zw是相等的,没有变化。所以ComputeScreenPos得到的w就等于裁剪空间下的w,而根据投影矩阵,裁剪空间下的w就等于视角空间下的-z。所以直接获取screenPos的w就相当于直接获取到了物体在视角空间下的深度值,所以也可以这么写:
  1. half s_depth = LinearEyeDepth(depth_2);   //不透明物体的线性深度
  2. half t_depth =  screenPos.w; //半透明物体的线性深度
复制代码
阶段性展示
5.采样渐变贴图
  1. fixed4 gradientColor = tex2D(_GradientTex , float2(sin(min(_Range.y , deltaDepth)/_Range.y),1));
复制代码
这样一来加上高光等效果就上档次了。
///
接着我们把海浪做了,海浪也是借鉴于那篇文章,不过有所减少。
//海浪
  1. float3 n = tex2D(_NoiseTex , i.uv).rgb;
  2. fixed3 w = tex2D(_WaveTex , float2 ( sin( _Time.y+ min(_Range.x, deltaDepth)/_Range.x) , 1) ).rgb;
  3. float rz = 1 - (min(_Range.z , deltaDepth) / _Range.z );
复制代码
最后输出颜色时加上w*rz就可以产生这个效果。
///
再加入反射,这里我用了平面反射。po一个链接中有目前常见的所有反射效果
https://blog.csdn.net/puppet_master/article/details/80808486
  1. sampler2D _ReflectionTex;
复制代码
这里的用proj的xy除以proj的z来得到视口空间的坐标。具体原理在上面的链接中,讲的很好!概括来说就是新建一个相机渲染反射的图像,然后我们把渲染好的图像进行偏移。
  1. fixed3 reflectionCol = tex2D(_ReflectionTex, i.proj.xy/i.proj.w).rgb ;
复制代码
看一下效果
之后是折射,就是抓屏。我在最终效果中没有加入这个,因为我们前面生成的渐变色已经很好的把水下的样子渲染出来了,而且效果不错。不过如果是岸边比较浅的话还是用抓屏更好一点。直接上代码。
  1. GrabPass{"_GrabTex"}
复制代码
//得到对应被抓取屏幕图像的采样坐标
  1. o.scrPos = ComputeGrabScreenPos(o.pos);
  2. fixed3 refractColor = (tex2D( _GrabTex, i.scrPos.xy/i.scrPos.w).rgb );
复制代码
效果图
///
好了有了折射有了反射 我们就可以快乐的菲涅耳了。
不过我实验了很多菲涅耳公式的简单变形式,效果并不佳。而且用折射和反射的菲涅耳效果更不好,所以我最终抛弃了折射,直接用光照模型结果和反射做了菲涅耳,说实话效果同样一般,但正在继续改进中。
  1. fixed fresnel =  pow((1 - (dot(worldNormal,viewDir))),5);
  2. float3 finalCol = diffuse * gradientColor.rgb + specular + ambient;
  3. fixed3 f = lerp(finalCol,reflectionCol,saturate(fresnel))*atten;
复制代码
最终输出,Alpha用的是一个float值采样的深度值。
  1. float Alpha = min(_Range.w, deltaDepth)/_Range.w; //透明度
  2. return float4((f + w * rz), Alpha);
复制代码
源文件中还加了法线融合和焦散的初始代码,但具体的并没有完美实现就不放效果了。
我想加入水波纹的交互但我不太会,有人有学习链接请评论出来,谢谢!
这是现阶段的输出效果。
以上是我二十天所学习到的水体渲染 学习笔记和总结。之后我会把有用的链接放在底下。
2020.8.10
如果大佬看到觉得哪里不好,改了的话一定告诉我。
这篇为学习笔记,如果代码雷同,那确实是我copy的(菜。


https://blog.csdn.net/u011076940/article/details/88018568/ unity水体渲染目录
https://www.jianshu.com/p/2b0e3f7f15b4 unity海洋水体渲染
https://github.com/QianMo/Game-Programmer-Study-Notes/tree/master/Content/%E3%80%8AGPU%20Gems%201%E3%80%8B%E5%85%A8%E4%B9%A6%E6%8F%90%E7%82%BC%E6%80%BB%E7%BB%93#%E4%B9%A6%E6%9C%AC%E9%85%8D%E5%A5%97%E8%B5%84%E6%BA%90%E4%B8%8E%E6%BA%90%E4%BB%A3%E7%A0%81%E4%B8%8B%E8%BD%BD GPU GEMS 第一章水体渲染

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2021-4-28 09:45 | 显示全部楼层
可以可以  参考一下
发表于 2021-4-28 09:54 | 显示全部楼层
好滴!
发表于 2021-4-28 09:58 | 显示全部楼层
你就在腾讯实习TA了?你是技术出身还是美术啊
发表于 2021-4-28 09:59 | 显示全部楼层
搜嘎。。图形程序嘛?
发表于 2021-4-28 10:01 | 显示全部楼层
技术美术哈 不是图程
发表于 2021-4-28 10:09 | 显示全部楼层
厉害厉害~~~
发表于 2021-4-28 10:19 | 显示全部楼层
加油!
发表于 2021-4-28 10:27 | 显示全部楼层
优秀
发表于 2021-4-28 10:34 | 显示全部楼层
大家都喜欢各大平台用一个网名一个头像的吗哈哈哈哈
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-23 11:33 , Processed in 0.140714 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表