在URP实现水面效果

  欢迎参与讨论,转载请注明出处。

前言

  Demo的场景也到了做水面的时候了,在涉及技术之前首先要确定的是美术表达:当然大体上也就是卡通水与写实水的抉择,最终决定是做出《伊苏:起源》
那样的写实水(注重扭曲、透视、无形变),并在此之上现代化。
0

上色

  首先我们先找个小池子作为试验场地——这样利于观测,那么很显然密室场景的熔岩就可以暂退了:
1
  水面的本质很简单,它就是个面片而已(不论海浪)。最直接的第一步自然是上色:
2
  上色之后自然是透视,把材质设置为Transparent,调整下透明度:
3
  很好,其实对于一些游戏的低画质,这个水面已经是成品了。当然这也太捞了,继续演进——

扭曲的准备

  对于水面效果的重点自然是扭曲了,处于水中的部分都会因为光的折射而变化。当然我们实际做起来并不会遵照这些大道理,看着是那么回事就得了(图形学第一定律)。最简单的做法自然是把对象渲染完毕后的画面截获,水面材质再选取合适的画面部分显示,并基于此加入扭曲——
  对于Built-In管线而言,想做到这点使用GrabPass即可,这方面的实现在《Unity Shader入门精要》已有详细做法。可是由于其设计不符合SRP的哲学,在URP已经被毙了,于是我们只能另寻他法了。
  当然实际上也没那么麻烦,思想已经有了,找到对应的实现方法即可:对象渲染完毕后的画面生成在URP可以通过管线设置文件勾选Opaque Texture实现,然后便可在Shader声明_CameraColorTexture调用。
4
  当然仅仅如此会有个问题:此图的生成时机是渲染所有非透明(Opaque)对象后,对于具有透明度的对象(Transparent)的渲染时机是在此之后的,这样水面里将会看不到Transparent对象了。对于此有两个解决方案:

  • 修改源码,将生成时机调到Transparent渲染之后。
  • 利用RenderFeature自行在合适的时机生成画面Texture。

  经过项目实际情况的考虑,我选择修改源码(具体修改在MyURP)。在Frame Debugger可以看到渲染时机已经变为Transparent之后了:
5
  做到这步只能算是准备好了子弹,接下来还要制造枪械:由于自带的Shader Pass的渲染时机并不在生成_CameraColorTexture之后,所以我们需要利用RenderFeature构建个渲染时机生成之后的环节。这里直接使用URP自带的Render Objects即可满足:
6
  如此只要Shader里Tag名为Grab的Pass,都将会在此RenderFeature进行渲染。接下来便是完成Shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
SubShader
{
Pass
{
Tags { "LightMode" = "Grab" }
Blend [_SrcBlend][_DstBlend]
ZWrite[_ZWrite]
Cull [_Cull]
HLSLPROGRAM
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma target 2.0
#pragma vertex Vert
#pragma fragment Frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
half4 _Color;
CBUFFER_END
TEXTURE2D(_CameraColorTexture); SAMPLER(sampler_CameraColorTexture);
struct Attributes
{
half4 positionOS : POSITION;
};
struct Varyings
{
half4 positionCS : SV_POSITION;
half4 screenPos : TEXCOORD0;
};
Varyings Vert(Attributes input)
{
Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
output.positionCS = vertexInput.positionCS;
output.screenPos = ComputeScreenPos(output.positionCS);
return output;
}
half4 Frag(Varyings input) : SV_Target
{
half3 color = SAMPLE_TEXTURE2D(_CameraColorTexture, sampler_CameraColorTexture, input.screenPos.xy / input.screenPos.w);
return half4(_Color.rgb * color, _Color.a);
}
ENDHLSL
}
}

  Shader实现与一般形式十分相似,主要在于用上了_CameraColorTexture以及ComputeScreenPos函数,看看效果先:
7
  看得出效果还是有所不同的,毕竟现在水面显示的不再是一层半透明蓝色了,而是原有画面的基础上调色。现在万事俱备,只欠东风了——

扭曲的实现

  实现扭曲我们需要一张表达水面的法线贴图,或者噪声贴图也行。本质上是偏移UV,以产生扭曲的结果。我选择使用法线贴图,因为后续也有用到。
  水面法线贴图的生产我并不了解,目前是随便找张不规则图形的基础上使用Unity自带的Create from Grayscale生成的,效果居然还不错:
8
  应用起来也很简单,获取法线贴图的xy数据加到screenPos.xy即可。当然仅此而已的话水面是不会动的,所以我们还可以加个与时间挂钩的偏移值,以推动法线贴图的uv,便可产生动起来的效果:

1
2
3
4
5
6
7
8
9
10
11
half4 Frag(Varyings input) : SV_Target
{
half2 speed = _Speed * _Time.y * 0.01;
half3 bump = UnpackNormal(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv.zw + speed)).rgb;
half2 offset = bump.xy;
input.screenPos.xy += offset * input.screenPos.z;
half3 color = SAMPLE_TEXTURE2D(_CameraColorTexture, sampler_CameraColorTexture, input.screenPos.xy / input.screenPos.w);
return half4(_Color.rgb * color, _Color.a);
}

9

  不错不错,对于某些游戏而言,到了这步也算完成了。但还不够——

着色

  目前有一个很明显的不足:虽然有了扭曲,但水面还是平平的一片蓝色,显然是缺乏明暗的体现。此时先前的法线贴图便可再次派上用场了:结合法线来做漫反射(Diffuse)效果。当然我们还不能直接使用取得的法线,还得将其转换至世界空间才行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Varyings Vert(Attributes input)
{
...
VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
half3 viewDirWS = GetCameraPositionWS() - vertexInput.positionWS;
output.TtoW0 = half4(normalInput.tangentWS.x, normalInput.bitangentWS.x, normalInput.normalWS.x, viewDirWS.x);
output.TtoW1 = half4(normalInput.tangentWS.y, normalInput.bitangentWS.y, normalInput.normalWS.y, viewDirWS.y);
output.TtoW2 = half4(normalInput.tangentWS.z, normalInput.bitangentWS.z, normalInput.normalWS.z, viewDirWS.z);
}
half4 Frag(Varyings input) : SV_Target
{
...
// 书接上文的bump
bump = normalize(half3(dot(input.TtoW0.xyz, bump), dot(input.TtoW1.xyz, bump), dot(input.TtoW2.xyz, bump)));
Light light = GetMainLight();
...
color = Diffuse(color, light.direction, bump);
}
half3 Diffuse(half3 color, half3 lightDir, half3 normal)
{
half NDotL = dot(normal, lightDir) + 0.5 * 0.5;
return color * NDotL;
}

  这里漫反射用的是半兰伯特(Half-Lambert),这是为了保证水面的亮度足够,看看效果:
10
  嗯,有点味道了。再加个高光看看吧:

1
2
3
4
5
6
7
8
9
10
11
half3 Specular(half3 color, half3 lightDir, half3 viewDir, half3 normal)
{
half3 halfVec = normalize(lightDir + viewDir);
half NdotH = dot(normal, halfVec);
NdotH = saturate(NdotH);
half v = pow(NdotH, _Smoothness);
v *= _SpecularStrength;
return color + color * v;
}

11
  越来越有味了,不过感觉这种高光不够突出光点,加个Step试试:

1
2
3
half v = pow(NdotH, _Smoothness);
v = step(_SpecularRamp, v);
v *= _SpecularStrength;

12
  不错不错,就这样吧,到实际场合看看。

反射

13
  目前的效果如上,总的来说算是OK了,但感觉还是差了点什么……没错,就是反射。起初我很自然而然的脑补认为要让周边的岩石草木投射在水面,为此我尝试了各种方案(反射探针、反射摄像机、平面反射……)都不满意,最终发现这纯属脑补了。实际由于视角原因是达不到那样的效果的,能够反射的内容基本会与折射重叠。醒悟之后发现最合适的反射内容只有纵身跳入的人物以及天空罢了:前者的出现场合太少了,对于后者与其用各种反射手段,还不如直接弄张天空贴图完事。

1
2
3
4
5
6
7
8
9
10
11
12
half4 Frag(Varyings input) : SV_Target
{
...
half3 texColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv.xy + speed);
half3 color = lerp(1, texColor, _Fresnel);
color = Diffuse(color, light.direction, bump);
color *= SAMPLE_TEXTURE2D(_CameraColorTexture, sampler_CameraColorTexture, input.screenPos.xy / input.screenPos.w);
color = Specular(color, light.direction, viewDir, bump);
return half4(_Color.rgb * color, _Color.a);
}

  弄了张天空贴图,结合扭曲所用的偏移值进行uv移动,使用_Fresnel控制反射与折射的比例。注意这里的_Fresnel仅仅是个0-1的参数,并非是真正的菲涅耳系数(由于视角关系根本用不到)。来对比下吧:
14
15
  这样的假反射在美术上的意义主要是能让水的颜色没那么单调,并且由于贴图是移动的,也带来了更多的动感。

后记

  最后加上点互动特效,有那么点意思了:
16
  在加这波粒子特效时也遇到了不少问题,也多了一些想要实现的东西。限于篇幅只能留待日后了。