【Unity Shader】Plane实现风格化水

写在前面

长文警告!!!!!

很久没更新博客了,,这次是要做一个风格化水效果,是基于Plane着色实现水面效果。

项目:Unity 2017.4.40f1 Build-in,因此实现过程会跟URP有些出入(例如获取相机深度图等等),但思路都是一样的。


前期准备

效果拆解

以《RIME》

【Unity Shader】Plane实现风格化水_第1张图片

和《原神》为例:

想实现的是二者融合的感觉,总结一下包含的基本效果:

  • 随着深浅变化的水颜色:浅水域湖蓝色,深水域天蓝色
  • 水面反射:反射天空盒
  • 水面折射:即折射带来的扭曲效果,类似上面RIME第一张图里那种水底的扭曲
  • 水表面波纹
  • 水底扰动:浅水域水底会有扰动效果?
  • 岸边的浮沫:《原神》没有岸边浮沫,那就参考RIME的来

实现一个基本的水效果之外,有时间的话还会加上人物和水的交互涟漪效果。

模型准备

由于时间原因,先Plane搭建最简单的沙滩+水面,沙滩给个纹理:

【Unity Shader】Plane实现风格化水_第2张图片

渲染路径

还是选择前向渲染。

为什么要在这提一句路径呢?因为看了挺多关于水渲染的文章,很多人是在日本大佬那篇文章的基础上进行完善的,而他Camera的Rendering Path设置的是延迟渲染,Gbuffer的话根本不需要考虑深度问题。所以Shader也没有设置Queue

如果选择前向渲染,一定要注意在Shader里规范好渲染顺序:

【Unity Shader】Plane实现风格化水_第3张图片

不然会出现这样的错误:

【Unity Shader】Plane实现风格化水_第4张图片

另外出现错误擅用Frame Debugger,能方便快捷的找到错误点,例如可以从这里发现,水和沙滩是同时考虑成Opaque被渲染的:

【Unity Shader】Plane实现风格化水_第5张图片

补充队列后就正常了:

【Unity Shader】Plane实现风格化水_第6张图片

emmmm,果然多练效果能加深理解,,

简述计算深度的流程

既然讨论到depth,首先有必要搞清楚depth在渲染管线中哪一环节起作用——光栅化阶段,GPU会根据上一阶段(屏幕映射后,传递屏幕坐标系下的顶点位置和一些深度、法线等信息)传递过来的当前像素在每个Mesh(三角形)上对应的深度值,去判断当前像素位置到底显示那个Mesh(三角形)信息。

像这次“获取相机看到的深度”、还有之前实现扫描效果需要实现的“基于深度重建世界坐标扫描”这类需求,都需要经过相同的一套流程获取深度值,再基于深度值再去做进一步操作,这套流程大概是:

获取相机深度图  -> 采样深度图 -> 深度转线性变化(我们希望实现随着深度变化)

终于有机会理一遍:

*获取相机DepthTexture

Build-in管线下,我们需要告诉Camera需要获取深度图——要挂脚本给相机开启相机深度(这是别的文章里看来的,但我后面实现没有挂脚本也能拿到深度图?):

GetComponent().depthTextureMode = DepthTextureMode.Depth;

或者参考《入门精要》的:

void OnEnable() {
    Camera.main.depthTextureMode |= DepthTextureMode.Depth;
}

 开启后,可以FrameDebugger一下,会发现流程中已经加入了获取深度纹理的Pass:

【Unity Shader】Plane实现风格化水_第7张图片

其中,DepthTexture是在ShadowCaster Pass中被渲染的,由于我这里场景中所有物体给了默认Shader,Shader中默认Fallback"Duffuse"就包含了ShadowCaster这一Pass。

Shader想使用的话,直接声明Unity给的全局变量_CameraDepthTexture就可以用了:

sampler2D _CameraDepthTexture;

有时会发现有人这样定义: 

UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture); // 声明深度纹理

这是Unity提供的另一些变量,一步一步涉及到的变量整理如下,追溯到最后实际上干的事情跟简单的sampler2D一样:

#define UNITY_DECLARE_DEPTH_TEXTURE(tex) UNITY_DECLARE_TEX2DARRAY (tex)

...

// 2D array syntax for hlsl2glsl and surface shader analysis
    #if defined(UNITY_COMPILER_HLSL2GLSL) || defined(SHADER_TARGET_SURFACE_ANALYSIS)
        #define UNITY_DECLARE_TEX2DARRAY(tex) sampler2DArray tex

...

// surface shader analysis; just pretend that 2D arrays are cubemaps
    #if defined(SHADER_TARGET_SURFACE_ANALYSIS)
        #define sampler2DArray samplerCUBE

采样深度图

需要在顶点shader里计算齐次坐标系下的屏幕坐标值,

o.positionCS = UnityObjectToClipPos(v.positionOS);
o.screenPos = ComputeScreenPos(o.positionCS);

其中ComputeScreenPos()是Unity Shader的内置函数,关于这个推导就不展开细说了,可以看这篇文章:Unity Shader中的ComputeScreenPos函数或者直接去看《入门精要》的4.9.3小节,讲得很详细。

此外,还需要转换成从人眼出发的深度(观察线性深度?就是Eye Depth),UnityCG.cginc中有给到计算的函数:

// Depth render texture helpers
#define DECODE_EYEDEPTH(i) LinearEyeDepth(i)
#define COMPUTE_EYEDEPTH(o) o = -UnityObjectToViewPos( v.vertex ).z

就是计算出顶点在观察空间中的z分量,为后面做深度差做准备,取负是因为观察空间的z轴是翻转的。(另外,因为前面因为写shader习惯把vertex直接替换成positionCS,这里发现Unity定义COMPUTE_EYEDEPTH()的时候默认是直接取v.vertex的,意味着还是换成vertex比较好?还是改回来,,,):

COMPUTE_EYEDEPTH(o.screenPos.z); // 线性变化

*补充一点,实际上根据透视投影矩阵(正交投影就不是了,w恒为1),裁剪空间的zw和观察空间的zw一致,而裁剪空间的w实际上就是观察空间的-z,那这一步完全可以省略,后面赋值的时候直接取o.screenPos.w就行了.

接着在fragment shader里:

float depth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));

其中,UNITY_SAMPLER_DEPTH是在HLSLSupport.cginc中定义的,用以获取r通道储存的深度值:

// Deprecated; use SAMPLE_DEPTH_TEXTURE & SAMPLE_DEPTH_TEXTURE_PROJ instead
#if defined(SHADER_API_PSP2)
#   define UNITY_SAMPLE_DEPTH(value) (value).r
#else
#   define UNITY_SAMPLE_DEPTH(value) (value).r
#endif

所以上面那行代码直接可以写成这样:

float depth = tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)).r;

此外,tex2Dproj定义如下,就是做了一个/.w的操作,事实上跟tex2D一模一样:

#if defined(SHADER_API_PSP2)
    // For tex2Dproj the PSP2 cg compiler doesn't like casting half3/4 to
    // float3/4 with swizzle (optimizer generates invalid assembly), so declare
    // explicit versions for half3/4
    half4 tex2Dproj(sampler2D s, in half3 t)        { return tex2D(s, t.xy / t.z); }
    half4 tex2Dproj(sampler2D s, in half4 t)        { return tex2D(s, t.xy / t.w); }

这样的话,以下两种方式道理是一样的:

// tex2Dproj
float depth = tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)).r;

// tex2D
float depth = tex2D(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos.xy/i.screenPos.w)).r;

最后还有个UNITY_PROJ_COORD,在HLSLSupport.cginc中定义如下:

#if defined(SHADER_API_PSP2)
#define UNITY_BUGGY_TEX2DPROJ4
#define UNITY_PROJ_COORD(a) (a).xyw
#else
#define UNITY_PROJ_COORD(a) a
#endif

感觉就是Unity根据平台API做了一些小规范? 

除了上面那个计算depth的方法,在其他人的文章里还有可能遇到如下采样定义:

float depth = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos));

 其中,SAMPLE_DEPTH_TEXTURE_PROJ定义如下:

 #undef SAMPLE_DEPTH_TEXTURE_PROJ
    #define SAMPLE_DEPTH_TEXTURE_PROJ(sampler, uv) UNITY_SAMPLE_TEX2DARRAY(sampler, float3((uv).x/(uv).w, (uv).y/(uv).w, (float)unity_StereoEyeIndex)).r

做的工作都是一样的,只不过是Unity把各种各样的不同计算方式封装起来,方便我们去直接使用。

深度转线性变化

多数时候我们希望基于深度做的效果变化是均匀的,但事实上Depth Texture储存的深度值不是线性的,具体原因这里就不多说啦,可以参考【Unity】深度图(Depth Texture)的简单介绍,所以我们最后还需要一步:

depth = LinearEyeDepth(depth);

Unity在UnityCG.cginc中提供了把z-buffer里储存的值转变成线性变化深度的函数:

// Z buffer to linear 0..1 depth
inline float Linear01Depth( float z )
{
    return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}
// Z buffer to linear depth
inline float LinearEyeDepth( float z )
{
    return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}

那么一套下来写进Pass里就是:

		Pass {
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile_fwdbase

			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
		
			UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture); // 声明深度纹理
			

			struct appdata {
				float4 vertex : POSITION;
			};

			struct v2f {
				float4 vertex : SV_POSITION;
				float4 screenPos  : TEXCOORD1;
				
			};

			v2f vert (appdata v) {
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.worldPos = mul(unity_ObjectToWorld, v.vertex.xyz);
				o.screenPos = ComputeScreenPos(o.vertex); // 计算屏幕坐标
				COMPUTE_EYEDEPTH(o.screenPos.z);          // 线性变化
				return o;
			}

			fixed4 frag (v2f i) : SV_TARGET {
				float depth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));  // 采样纹理获得深度值
				depth = Linear01Depth(depth);  // 深度线性变化
				return fixed4(1,1,1,1);
			}
			ENDCG
	    }

1 水深浅区域颜色

首先聊第一种方案:

深水区和浅水区水的颜色不一样,我们要明白,我们是用一个Plane着色去模拟水,所以我们是要给平面上的点上色,就要计算(plane上每个片元的深度值-场景深度值),大概画了画(图里红色那部分):

【Unity Shader】Plane实现风格化水_第8张图片

刚才计算的那个深度值是场景的深度值depth1,片元深度depth2实际上就是ScreenPos的z分量(不知道为什么的直接看《入门精要》4.9.3啦):

float depthFrag = i.screenPos.z;   // 当前水片元深度
float depth = saturate(depthFrag - depthScene); // 差值

虽然大部分基于深度做水颜色变化、浪花的文章都是用上面这个方法,但是这个方法深度值会随着相机视角的变化而变化,,总之会出一些很怪的效果,特别是后来实现浪花的时候,,效果异常的丑,而且状况百出,就像这样:

【Unity Shader】Plane实现风格化水_第9张图片

一切问题都出自深度不固定,那我们就让他固定!

计算都是比较基础的了,之前做扫描的时候就学习过一次,就不解释原理了,过程的话可以看这篇文章:Unity从深度缓冲重建世界空间位置=

这里就直接截取我的Shader:

				// 采样获取深度值
				float depthScene = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));
				depthScene = LinearEyeDepth(depthScene); // 场景深度
								
				// 1.基于相机的深度差
				// float depthFrag = i.screenPos.z;   // 当前水片元深度
				// float depthZ = saturate((depthScene - depthFrag)/_DepthAtten); // 差值

				// 2.脱离相机的垂直深度差
				i.worldSpaceDir *= -depthScene / i.viewSpaceZ;
				float3 worldPosScene = _WorldSpaceCameraPos + i.worldSpaceDir; // 沿着向量
				float depthZ =  saturate((worldPos - worldPosScene).y / _DepthAtten);

1.1 他人方案

判断依据有了,接下来是上色环节,先来简单看看别人是怎么做水颜色的:

【Unity URP】风格化水体渲染 - 知乎 (zhihu.com)这篇文章水颜色是直接根据差值lerp:

【Unity Shader】Plane实现风格化水_第10张图片

Unity中水的简单实现 - 知乎 (zhihu.com) 这篇文章是用纹理来规定深浅度,采样后lerp颜色:

【Unity Shader】Plane实现风格化水_第11张图片

Unity Shader 水体渲染 - 知乎 (zhihu.com)这篇文章也是,采样了一个渐变纹理:

【Unity Shader】Plane实现风格化水_第12张图片

 最后是这一篇日本大佬的文章:【Unity , shader】原神の海を再現したい - Qiita,自定义cos渐变色函数!!!达到渐变色且不用纹理

【Unity Shader】Plane实现风格化水_第13张图片

​但是这个方法感觉对美术不太友好?类似于需要预先调整颜色给定公式,不能即时查看颜色,但是这个方案真的很吸引人!!自定义渐变色可太酷了,直接采样渐变纹理的话方法挺简单的,这里就直接尝试这位日本大佬的方案。

1.2 自定义渐变色

网站指路:grad - Cosine Gradient in Multiple Color Spaces (sp4ghet.github.io)

调一调,虽然看上去参数非常多,试了一下其实是能感觉到每个参数控制的是什么,让颜色尽量接近原神里的:

【Unity Shader】Plane实现风格化水_第14张图片

调好之后,会自动生成代码:

【Unity Shader】Plane实现风格化水_第15张图片

手动给他转成Cg/HLSL就行,我这里的话是转成Cg:

// 生成自定义渐变色函数
			float4 cosine_gradient(float x, float4 phase, float4 amp, float4 freq, float4 offset) {
				float TAU = 2 * 3.14159265;
				phase *= TAU;
				x *= TAU;
				return float4(
					offset.r + amp.r * 0.5 * cos(x * freq.r + phase.r) + 0.5,
					offset.g + amp.g * 0.5 * cos(x * freq.g + phase.g) + 0.5,
					offset.b + amp.b * 0.5 * cos(x * freq.b + phase.b) + 0.5,
					offset.a + amp.a * 0.5 * cos(x * freq.a + phase.a) + 0.5
				);
			}

			fixed3 toRGB(float3 grad) {
				return grad.rgb;
			}

然后fragment shader里加上,加了一个_ColorAtten控制颜色变化、_DepthAtten控制深浅程度变化:

			fixed4 frag (v2f i) : SV_TARGET {
				
				float depthScene = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos)));
				depthScene = LinearEyeDepth(depthScene); // 场景深度
				float depthFrag = i.screenPos.z;   // 当前水片元深度
				float depthZ = saturate((depthScene - depthFrag)/_DepthAtten); // 差值
				// 生成的值
				const float4 phases = float4(0.28, 0.44, 0.00, 0.);
				const float4 amplitudes = float4(3.27, 0.14, 0.39, 0.);
				const float4 frequencies = float4(0.00, 0.67, 0.28, 0.);
				const float4 offsets = float4(0.04, 0.14, 0.14, 0.);
				fixed4 cos_grad = cosine_gradient(saturate(_ColorAtten - depthZ), phases, amplitudes, frequencies, offsets);
				cos_grad = clamp(cos_grad,0,1);
				fixed4 color = fixed4(toRGB(cos_grad),1);
				// 水越浅,越透明,刚好可以用depthZ来表示,值越小越浅
				float alpha = saturate(depthZ);
				color.a = alpha;
				return color;
			}

Alpha做了处理,随着深浅控制.a值。

1.3 效果

最后颜色效果(_ColorAtten=1.45,_DepthAtten=10):

【Unity Shader】Plane实现风格化水_第16张图片

2 水面波纹-处理法线

2.1 拿法线纹理

处理完颜色,开始给水波了。正常想法是给个噪声贴图实现。尝试RenderDoc+MuMu模拟器抓帧,大概找了白天晚上两个一样的地方,水渲染的Pass都出现了这两张法线纹理:

【Unity Shader】Plane实现风格化水_第17张图片

那就拿这两张法线纹理叠加做出来的水波效果。因为不知道为什么模拟器只能选Vulkan或DX,用DX连RenderDoc会崩,Vulkand的shader又看不懂,所以目前只能做到拿到法线纹理了(悲)

2.2 两次采样叠加

那就开始,基于上面法线纹理,_WaveSpeed控制两次UV采样的程度,两张纹理效果叠加:

// 两套UV
				o.normalUV1.xy = o.uv + float2(_Time.x * _WaveSpeed.x, _Time.x * _WaveSpeed.y);
                o.normalUV1.zw = o.uv + float2(_Time.x * _WaveSpeed.z, _Time.x * _WaveSpeed.w);

然后就做正常的世界空间法线变换+叠加两套UV采样效果:

// 采样法线贴图,两次叠加
				float3 normal = UnpackNormal(tex2D(_NormalTex0, i.normalUV1.xy))*0.5 + UnpackNormal(tex2D(_NormalTex1, i.normalUV1.zw))*0.5;
				normal.xy *= _NormalScale;
				normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy)));
				float3 worldNormal = normalize(float3(dot(i.TtoW0.xyz, normal), dot(i.TtoW1.xyz, normal), dot(i.TtoW2.xyz, normal)));

做到这里实际上只是提供了一个被扰动的法线,真实的体现出波浪的效果还需要结合光照计算,而且后期需要调整,因为考虑到原神本身海面并没有扰动那么明显,后面一定会做相应的调整。

*不加顶点动画更省事的方案

做的过程中突然看到有篇文章评论区提出了这样一种方法:

啊啊啊!确实!既然水面一直是平面,且没有顶点动画:那为什么还要做复杂的法线计算?毕竟算来算去Plane上顶点的法线方向始终朝向y轴正方向,即float3(0,1,0),直接采样法线纹理扭曲这个方向就行了!!

意味着,这个:

float3 worldPos = mul(unity_ObjectToWorld, v.vertex.xyz);
				float3 worldNormal = UnityObjectToWorldNormal(v.normal).xyz;
                float3 worldTangent = UnityObjectToWorldDir(v.tangent).xyz;
                float3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;

				o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
                o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
                o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);

和这个:

float3 worldPos = mul(unity_ObjectToWorld, v.vertex.xyz);
				float3 worldNormal = float3(0,1,0);
                float3 worldTangent = float3(-1,0,0);
                float3 worldBinormal = float3(0,0,-1);

				o.TtoW0 = float4(-1, 0, 0, worldPos.x);
                o.TtoW1 = float4(0, 0, 1, worldPos.y);
                o.TtoW2 = float4(0, -1, 0, worldPos.z);

 是完全等价的!那就不需要算这么多了,,,直接在片元里计算就行。但是,我的水后面可能需要加顶点动画,所以这里就先不做简化!

3 基础光照

我们再回到效果初衷——无论是《RIME》里的还是《原神》里的水,水面高光都不至于完全复刻真实水体的那种波光粼粼,例如Unity只在一个面片上实现真实水体渲染 - 知乎 (zhihu.com)这篇文章最后实现的效果:

【Unity Shader】Plane实现风格化水_第18张图片

所以光照计算也不至于PBR,先考虑Diffuse

3.1 基础色+高光

高光specular考虑成Blinn-Phong高光项,

fixed3 specular = _SpecularColor.rgb * _SpecularAtten * pow(ndoth, _Gloss);

输出diffuse+specular,这里加上了一个CubeMap天空盒,所以光源在天空上没显示了:

【Unity Shader】Plane实现风格化水_第19张图片

3.2 静态的:反射天空盒+菲涅尔

目前我了解到的反射方案有:CubeMap、Reflection Probe、PlanarReflection、ScreenSpaceReflection(SSR)、还有SSR+PlanarRelflection,这里先实现一个最简单直接的CubeMap:

// 反射天空盒
float3 reflectDir = reflect(-viewDir, worldNormal);
fixed3 reflecColor = UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, reflectDir);

单独输出的话:

【Unity Shader】Plane实现风格化水_第20张图片

啊肯定不能这样,,还要考虑菲涅尔的——接近天空的部分才反射的很明显,靠近实现的地方几乎没什么反射,能看到水底,这里用最基础的Fresnel:

// 菲涅尔 Fresnel-Schlick
			inline float3 Unity_Fresnel(float3 F0, float cosA){
				float a = pow((1 - cosA), 5);
				return (F0 + (1 - F0) * a);
			}

片元shader里加上:

// 菲涅尔项
float F0 = 0.02;
float F = saturate(Unity_Fresnel(F0,dot(viewDir, worldNormal)) * _FresnelAtten);

...

fixed3 color = lerp(diffuse + specular , reflecColor, F);

下面是考虑菲涅尔和不考虑的对比:

【Unity Shader】Plane实现风格化水_第21张图片

【Unity Shader】Plane实现风格化水_第22张图片

效果不错,但很遗憾,,CubeMap始终是一种静态的反射方案,如果天空盒保持不动还好说,但像原神这种水面是需要跟动态天空盒配合实现反射的。而且水面上石头啊、人物走进的倒影也是没办法呈现出来的,所以秉持着做东西要能投入实际使用的原则,只有CubeMap方案的水体反射是不完整的。

4 动态的:反射探针+平面反射

4.1 实现基础平面反射

上面提到的那几个方法中,SSR是基于屏幕空间的,需要拿到法线+深度图,对于前向渲染性价比比较低,,平面反射虽然也很耗,但对于移动端相比SSR更好?(待验证),先来实践一下:

概述一下实现过程,

我们需要新建一个相机ReflectCamera,让该相机相对于主相机MainCamera关于水面(xz平面)对称,将渲染结果输出到一张RT,当我们渲染水面Plane上一点的时候,直接到这张RT上采样。采样也不是用原来的uv了,不理解的话可以像我这样假想一下:RT的内容就是水里面倒影的真实样子,也就是我们是要按RT原原本本应该呈现在屏幕上的样子,我们要做的是给他原封不动拿过来贴在我们MainCamera的画面里,所以采样RT要拿屏幕坐标采样

实现的脚本主体参考:Unity Shader-反射效果,另外原作者基于平面斜截反射相机的视锥体那部分我用起来有些问题,直接用Unity的API效果是正确的:

【Unity Shader】Plane实现风格化水_第23张图片

用作者的脚本会有问题:

【Unity Shader】Plane实现风格化水_第24张图片

找不出问题在哪儿,我就直接选择用Unity提供的APICalculateObliqueMatrix(clipPlane)计算出斜裁剪矩阵:

// 平面法线朝向
        var normal = transform.up;
        // 求与平面的倒影距离
        var d = -Vector3.Dot(normal, transform.position);
        // 平面到点距离
        var plane = new Vector4(normal.x, normal.y, normal.z, d);
        // 用逆转置矩阵将平面从世界空间变换到反射相机空间
        var viewSpacePlane = reflectionCamera.worldToCameraMatrix.inverse.transpose * plane;
        // 做斜视锥体投影矩阵
        var clipMatrix = Camera.current.CalculateObliqueMatrix(viewSpacePlane);
        reflectionCamera.projectionMatrix = clipMatrix;

然后在我们的Shader中加入:

// 平面反射
fixed4 reflectColor1 = tex2Dproj(_ReflectionTex, UNITY_PROJ_COORD(i.screenPos));
fixed3 color = lerp(diffuse + specular , reflectColor1, F);

基本的平面反射是完成了,可以看到有天空+水面物体的影子:

【Unity Shader】Plane实现风格化水_第25张图片

但是,,,之前的做的很好看的水面波纹效果消失了,因为worldNormal根本没有用上,效果——太怪了!


4.2 再次分析反射思路

新的一天!继续完善反射部分。这里让我们再回看一下《原神》画面,希望确定一下最终的反射效果。

可以发现《原神》对远处天空盒的反射处理(绿色框框)和对静处场景中静态物体的反射处理(红色框框)不同,红色框框处理的很尖锐,绿色框框就很柔和:

远处的天空柔和,相对静处的物体尖锐,也是挺合理的?我们再拿同一视角下,3种不同天空颜色和云层变化的水面反射效果对比看看:

远处由于菲涅尔会直接完全反射出天空的颜色,静处是绿绿的水体本身的颜色,远处画面还有一定的雾效加持。对了,我们还要需要明确一点,《原神》是延迟渲染,所以反射很可能直接基于SSR做?(由于逆向一时半会儿也看不到shader所以只能初步假设了)

基于此再回到我们实现的效果上,想要实现的是:菲涅尔(完成)+水体颜色(完成)+天空盒反射(待)+场景其他物体倒影(待),那么我们可以采取:反射探针CubeMap实现动态天空盒反射+平面反射实现场景物体水面倒影反射,需要以某种手段剔除掉天空盒的反射。

4.3 反射探针 动态CubeMap

我们在之前CubeMap基础上,在场景如图位置加入反射探针,并调整影响范围:

【Unity Shader】Plane实现风格化水_第26张图片

Culling Mask选择Nothing,只反射天空盒:

【Unity Shader】Plane实现风格化水_第27张图片

避免出现这种把场景中其他物体倒影也包括的情况:

【Unity Shader】Plane实现风格化水_第28张图片

加上菲涅尔+法线扰动,看看效果:

【Unity Shader】Plane实现风格化水_第29张图片

水面那道高光高光是太阳的,但是天空盒没有做程序化太阳所以天空盒看不到太阳...旋转CubeMap可以看到反射效果是实时更新的,太麻烦这里就不演示了。

4.4 平面反射 剔除掉天空盒

下一步就是加上4.1实现的平面反射的同时,把CubeMap的部分剔除掉。这个办法我尝试了很多效果都欠佳,直到看到了这篇文章Unity制作仿原神水面(2)——反射、白浪,这篇文章作者也遇到我的问题,只不过他没给CubeMap做动态处理。他也注意到了物体倒影很尖锐这一点:

【Unity Shader】Plane实现风格化水_第30张图片

但是他做的海水扰动不是采样法线纹理,而是自定义了一个噪声函数,这里我选择复用之前的法线纹理某一通道作为噪声去扰动ScreenPos:

// 平面反射
				// 加入扰动
				i.screenPos.x += normal00.x*5*depthZ;
				fixed4 reflectColor1 = tex2Dproj(_ReflectionTex, UNITY_PROJ_COORD(i.screenPos));

【Unity Shader】Plane实现风格化水_第31张图片

放个灰色方块模拟场景中的山体,越离的近的波浪效果越弱。

接下来就是融合CubeMap和平面反射的效果了,如果只是简单的透明度剔除:

			// 剔除天空盒
			// col1天空色 col2 反射物体色
			fixed4 blendSeaColor(fixed4 col1,fixed4 col2)
			{
				fixed4 col = col2 * col2.a + col1 * min(1,1.2 - col2.a);
				return col;
			}
fixed3 color = lerp(diffuse+ specular, blendSeaColor(reflectColor0,reflectColor1), F);

融合后的效果:

【Unity Shader】Plane实现风格化水_第32张图片

【Unity Shader】Plane实现风格化水_第33张图片

物体和云的扰动不同,物体的幅度更大,动态效果就不展示了。  

5 岸边浪花

感觉原神画面上具备的都有了?

【Unity Shader】Plane实现风格化水_第34张图片

参考一下RIME做一个实线浪花的效果:

【Unity Shader】Plane实现风格化水_第35张图片

做法感觉蛮简单的,肯定有采样贴图,之前不知道抓帧《原神》的哪个场景存了个这个图:

【Unity Shader】Plane实现风格化水_第36张图片

说起来《原神》里面水应该没有做浪花才对,,可能是其他的特效图吧,,长得挺像浪花?又有点像焦散效果贴图,可以先用它做浪花试试看?

				// 岸边海浪
				i.uv.y += _Time.x * _FoamSpeed ;
				i.uv.x += _SinTime.x * 0.04;
				fixed4 foamTex = tex2D(_FoamTex, i.uv.xy);
				float foamAlpha = ((foamTex.r  + foamTex.g)* depthZ); // 深度值加入透明度影响
				float boarder = step(depthZ, _FoamBorder); // _Border控制浪花显示范围
				fixed3 foamColor = smoothstep(0.5,0.7,foamAlpha*boarder) *_SpecularColor; // smoothstep控制

效果:

【Unity Shader】Plane实现风格化水_第37张图片

Shader栏:

【Unity Shader】Plane实现风格化水_第38张图片

这是一种非常基础的浪花实现效果,是十分依赖纹理的,效果也比较单一。事实上我更欣赏程序化生成浪花的方案,例如这篇文章Unity仿《原神》水渲染中提到的How to create a semi procedural cartoon foam shader (gamedeveloper.com)

由于时间问题,后面会抽时间完善这部分内容。


更新,如果是曲边的岸边,可以不是那种长长的线条,通过调整Tilling数值拿到想要的浪花形状:

【Unity Shader】Plane实现风格化水_第39张图片

还是那句话,这种基于纹理的简单浪花效果非常吃岸边的地形形状。

6 浅水域焦散效果

首先能想到的实现焦散的方法是采样一张贴图?做一下,Water Caustics Effect (Small) | OpenGameArt.org拿纹理:

【Unity Shader】Plane实现风格化水_第40张图片

因为想实现浅水域焦散,而且是水下,所以要用上面重建出来的世界坐标做焦散,,

// 焦散
				float2 causticUV = worldPosScene.xz * _CausticTex_ST.xy * (1 - _CausticSize) *10 + _CausticTex_ST.zw;
				float4 causticColor = tex2D(_CausticTex, float2(-causticUV.y + _CausticSpeed *0.1*sin(_Time.y), causticUV.x + _Time.x * _CausticSpeed* normal00.x* 0.01))*_CausticColor;
				color = lerp(color + causticColor, color, depthZ); // 加上浅水域焦散效果

效果:

连着做了3天,实在是有些疲惫了,,第一版就先这样。

还有很多效果没补充,后面会继续完善。 

参考

unity反射效果:移动端镜面反射,屏幕空间镜面反射实践 - 知乎 (zhihu.com)

Unity仿《原神》水渲染 - 知乎 (zhihu.com)

3D渲染技术分享:实时水面渲染方案(反射、折射、水深与水岸柔边) - 知乎 (zhihu.com)

Unity Shader-反射效果(CubeMap,Reflection Probe,Planar Reflection,Screen Space Reflection)

【Unity】深度图(Depth Texture)的简单介绍 - 知乎 (zhihu.com)

【Unity , shader】原神の海を再現したい - Qiita

Unity只在一个面片上实现真实水体渲染 - 知乎 (zhihu.com)

Unity制作仿原神水面(2)——反射、白浪 - 知乎 (zhihu.com)

Believable Caustics Reflections - Alan Zucconi

你可能感兴趣的:(Unity,Shader学习,unity,游戏引擎)