水体效果简介
这里讲的是模拟水体效果的一系列技术点。主要包括,着色模型、水体颜色、法线模拟水面流动、FlowMap控制水面流动、深度、折射、反射、焦散、泡沫、假SSS模拟等。下面具体说明。
着色模型
![]()
LightingMode有PBR、BlingPhong、None三种选项。默认使用PBR着色,可以为了极致的性能选择BlingPhong,甚至不着色直接输出albedo。
1 2 3 4 5 6 7 8
| #if defined(_PBR) half4 color = UniversalFragmentPBR(inputData, surfaceData); #elif defined(_BLING_PHONG) surfaceData.specular = lerp(_BlingPhongSpecColor.rgb, surfaceData.albedo, surfaceData.metallic); half4 color = UniversalFragmentBlinnPhong(inputData, surfaceData); #else half4 color = half4(surfaceData.albedo * surfaceData.occlusion, surfaceData.alpha); #endif
|
可以看到PBR的结果更加细腻,最明显的区别就是高光。PBR可能看到金黄色的高光,但是BlingPhong的高光需要调整。因此,使用了一个trick,额外提供了一个BlingPhong的高光颜色来近似,同时用金属度在高光颜色和albedo之间进行插值。
具体效果:
![]()
Output
output内有两个选项,第一个是在最终渲染结果的a通道上叠加一个透明度,用于整体调整透明度;一个是缩放最终的着色结果,用于整体调亮调暗。
具体效果:
![]()
水体颜色
![]()
水体分为浅水颜色和深水颜色。浅水区域即靠近岸边的区域,深水区域即其它区域。那么怎么区分,浅水区域和深水区域了?使用水深,即使用水的深度差。这个深度差是使用水底的深度和水面的深度相减得到,具体实现在深度部分会具体讲述。
那么,最终的颜色即使用浅水颜色和深水颜色插值得到,过渡算法即是使用深度差进行smoothstep。具体代码:
1
| half4 baseColor = lerp(_ShallowBaseColor, _DeepBaseColor, smoothstep(_WaterDepthAdd, _ShallowDepthCutOff + _WaterDepthAdd, _WaterDepthMultiple * sceneDeltaDepth));
|
ShallowDepthCutOff用于控制浅水的范围。至于WaterDepthAdd、WaterDepthMultiple是额外提供的调整水深度范围的参数。
具体效果:
![]()
可以看到调整ShallowDepthCutOff可以扩大或者缩小浅水区域的范围。
水体透明度
除去output对透明度的修改外,还有水体颜色的a通道对透明度的控制,比如浅水透明度更高,深水透明度更低,然后使用深度进行过渡。
更重要的是实现水体边缘的平滑过渡,否则水体边缘是硬边,无法做到与岸边的平滑融合。具体实现:
1
| color.a *= max(0.0, smoothstep(-0.001, _TransparentDepthCutOff, sceneDeltaDepth) - _TransparentAdd);
|
算法原理类似水体颜色过渡,使用深度差在-0.001(略小于0)和TransparentDepthCutOff之间进行smoothstep插值,然后乘到原始的透明度上。
具体效果:
![]()
深度
![]()
水面深度
水面深度指的是当前着色的像素点深度,这里我们要计算从摄像机开始的线性深度,具体代码如下:
1 2 3 4 5 6 7 8 9 10
| inline float GetRealtimeWaterEyeDepth(float4 projection) { float depth = projection.z / projection.w; #if !UNITY_REVERSED_Z depth = depth * 0.5 + 0.5; #endif return LinearEyeDepth(depth, _ZBufferParams); }
float waterDepth = GetRealtimeWaterEyeDepth(input.screenPos);
|
对于性能敏感的场景,我们也可以使用一张离线的深度图。这种情况下可以假设使用正交模型,从顶往下离线渲染一张RT保存了深度图。如OfflineDepth的设置所示,需要一个摄像机原点和一个正交投影范围,根据这些信息,那么还原离线线性深度的代码如下:
1 2 3 4 5 6 7 8 9 10 11
| inline float GetOfflineWaterEyeDepth(float3 positionWS) { float eyeDepth = _OfflineTextureWorldOrgin.y - positionWS.y;//离线相机从上往下拍摄 float n = _OfflineTextureCameraSettings.y; float f = _OfflineTextureCameraSettings.z;
eyeDepth = clamp(eyeDepth, n, f); return eyeDepth; }
float waterDepth = GetOfflineWaterEyeDepth(input.positionWS);
|
该函数实际上就是计算距离离线深度图相机的线性距离。
水底深度
同样分为实时和离线两种情况,实时深度需要从实时深度图读取深度再转换为线性深度;离线深度则是从离线深度图内读取深度,再根据正交投影信息转换为线性深度。
代码如下:
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
| inline float2 GetOfflineWorldUV(float3 positionWS) { return (positionWS.xz - (_OfflineTextureWorldOrgin.xz - _OfflineTextureCameraSettings.xx)) / (2 * _OfflineTextureCameraSettings.xx); }
inline float GetSceneEyeDepth(float2 uv) { #if _REALTIME_DEPTH_MAP float depth = SampleSceneDepth(uv); depth = LinearEyeDepth(depth, _ZBufferParams); #else float depth = SAMPLE_TEXTURE2D_X(_OfflineDepthMap, sampler_OfflineDepthMap, uv).r;//offline tool has fixed UNITY_REVERSED_Z float n = _OfflineTextureCameraSettings.y; float f = _OfflineTextureCameraSettings.z; depth = n + (f - n) * depth; #endif
return depth; }
#if _REALTIME_DEPTH_MAP float sceneDepth = GetSceneEyeDepth(screenUV); #else float sceneDepth = GetSceneEyeDepth(offlineWorldUV); #endif
|
当然,实时深度必须开启渲染管线的深度图设置。
归一化深度差
上面2个步骤的水底深度减去水面深度,得到的就是深度差,然后用这个深度除以最大深度差得到的就是归一化的深度差。具体代码如下:
1
| float sceneDeltaDepth = max((sceneDepth - waterDepth) / _MaxWaterDepthDiff, 0.0);
|
注意,MaxWaterDepthDiff是一个可调节的参数,用户可以方便控制最大的水深(可以超过或者小于实际的最大水深),这样可以整体调整整个水体的深度。
归一化深度差的用途
比如,前面说到的浅水区域和深水区域定义和过渡、水体边缘平滑过渡等。以及后续的焦散、泡沫等效果,也可以用深度差控制显示范围。
法线模拟水面流动
我们知道水面是流动的,要模拟流动主要有两种方式,一种是顶点动画,一种是在片元内移动法线。顶点动画比较适合模拟海浪,性能消耗比较大。这里,我们使用的是法线来模拟水面的流动。
SurfaceInputs内有一张法线输入,以及2个法线的ScaleSpeed;按照不同的移动速度采用出2个法线值,然后进行BlendNormal,作为最终的法线。
具体代码如下:
1 2 3 4 5 6 7 8 9 10 11
| float2 baseNormalUV = TRANSFORM_TEX(uv, _NormalMap); UVPanner1 = _Time.y * 0.1f * _NormalScaleSpeed1.yz; UVPanner2 = _Time.y * 0.1f * _NormalScaleSpeed2.yz;
half4 normalColor1 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner1); half4 normalColor2 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner2);
half3 normalTS1 = UnpackNormalScale(normalColor1, _NormalScaleSpeed1.x); half3 normalTS2 = UnpackNormalScale(normalColor2, _NormalScaleSpeed2.x);
outSurfaceData.normalTS = BlendNormal(normalTS1, normalTS2);
|
至于BlendNormal的实现,CommonMaterial.hlsl内具体代码:
1 2 3 4 5
| // assume compositing in tangent space real3 BlendNormal(real3 n1, real3 n2) { return normalize(real3(n1.xy * n2.z + n2.xy * n1.z, n1.z * n2.z)); }
|
可以参考文章:Normal Blend方法总结的Partial Derivative Blending部分。
FlowMap控制水面流动
![]()
上面我们实现了水面流动的模拟,但是有时候我们需要用贴图来控制水面的整体流动方向,这个时候就需要一种叫做FlowMap的技术。FlowMap实际上是一张记录了2D向量场的纹理,我们可以从这张图里面读取1个flowDir表示UV的移动方向;另外需要计算一个phase,用于表示当前的相位,结合flowDir可以获得2个UV;最后,用这个2个UV采用法线贴图,再进行插值即可。注意,插值算法也要依赖phase。详细的解释和FlowMap的应用可以参考文章:FlowMap技术介绍 和 案例展示。
具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| float2 baseNormalUV = TRANSFORM_TEX(uv, _NormalMap);
float2 flowDir = (SAMPLE_TEXTURE2D(_FlowMap, sampler_FlowMap, uv).rg * 2.0f - 1.0f) * (-_FlowDirSpeed); float phase0 = frac(_Time.y * _FlowTimeSpeed); float phase1 = frac(_Time.y * _FlowTimeSpeed + 0.5f); UVPanner1 = flowDir * phase0; UVPanner2 = flowDir * phase1;
half4 normalColor1 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner1); half4 normalColor2 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner2);
flowLerp = abs(phase0 - 0.5f) / 0.5f; half4 normalColor = lerp(normalColor1, normalColor2, flowLerp); outSurfaceData.normalTS = UnpackNormalScale(normalColor, _FlowNormalScale);
|
具体效果:
![]()
可以看到激活FlowMap后,水体流向会按照这张2D向量场贴图的方向流动。
折射
![]()
类似于深度的计算,同样有实时折射和离线折射两种方式。实时折射需要渲染管线开启颜色贴图,而离线折射则需要使用工具预先生成一张颜色贴图,类似于离线深度,这里同样使用自顶向下的离线相机拍摄一张离线颜色贴图。
具体代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| inline half3 GetRefractionColor(float2 uv, float2 noise, float sceneDeltaDepth) { half weight = saturate(1 - smoothstep(_WaterDepthAdd, _RefractionDepthCutOff + _WaterDepthAdd, sceneDeltaDepth)); uv += noise * _RefractionNoiseIntensity * 0.1;
#if _REALTIME_REFRACTION half3 sceneColor = SampleSceneColor(uv).rgb; #else half3 sceneColor = SAMPLE_TEXTURE2D_LOD(_OfflineRefractionMap, sampler_OfflineRefractionMap, uv, sceneDeltaDepth * 10 * _OfflineRefractionLodMultiple).rgb; #endif
return weight * sceneColor * _RefractionColor * _RefractionIntensity; }
#if _REALTIME_REFRACTION refractColor = GetRefractionColor(screenUV, inputData.normalWS.xz, sceneDeltaDepth); #elif _OFFLINE_REFRACTION refractColor = GetRefractionColor(offlineWorldUV, inputData.normalWS.xz, sceneDeltaDepth); #endif
|
实时折射
从上述代码可以看到,实时折射需要调用SampleSceneColor采样实时的颜色贴图,采样左边是屏幕空间uv。
离线折射
而离线折射,从一张OfflineRefractionMap内采样颜色信息,同时提供了贴图LOD控制。
叠加折射
折射颜色与反射颜色是通过菲涅尔算法插值后,再叠加到最终的着色结果上。
1 2 3 4 5 6 7 8
| inline half CalculateFresnelTerm(half3 normalWS, half3 viewDirectionWS) { return 1.0 - dot(normalWS, viewDirectionWS); }
half fresnelTerm = CalculateFresnelTerm(inputData.normalWS, inputData.viewDirectionWS);
color.rgb += lerp(refractColor, reflectColor, fresnelTerm);
|
这里的Fresnel使用了最简单的计算法线与视线夹角的实现。
具体效果
![]()
由于水是半透明的,不开启折射也能一定程度上看到水底的效果。因此,默认情况下,不开启折射以节省性能。切换到离线折射时候,由于没有设置贴图,默认是白色贴图会导致整体变凉。实时折射情况下,调整折射噪声的强度可以看到一定程度的水底扭曲。
反射
![]()
反射通常指的是反射周围环境的效果,由于一般水体面积很大,不需要精准反应反射物体的位置,因此不需要实时镜面反射。比较合适的方式是使用Cubemap或者反射探针反射。
Cubemap反射
1 2 3 4 5 6 7 8
| inline half3 GetCubemapReflection(half3 reflectVector) { #if _CUBEMAP_REFLECTION return SAMPLE_TEXTURECUBE_LOD(_ReflectionCubeMap, sampler_ReflectionCubeMap, reflectVector, _ReflectionCubemapLod).rgb * _ReflectionCubemapColor * _ReflectionCubemapIntensity; #else return half3(0, 0, 0); #endif }
|
Cubemap即显式指定一张固定的Cubemap,从这张图采样作为反射信息,同时支持指定贴图LOD。
Probe反射
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| inline half3 GetProbeReflection(half3 reflectVector, float3 positionWS, half smoothness, half occlusion) { #if _PROBE_REFLECTION reflectVector = BoxProjectedCubemapDirection(reflectVector, positionWS, unity_SpecCube0_ProbePosition, unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax); half mip = PerceptualRoughnessToMipmapLevel(PerceptualSmoothnessToPerceptualRoughness(smoothness)); half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflectVector, mip);
#if defined(UNITY_USE_NATIVE_HDR) half3 irradiance = encodedIrradiance.rgb; #else half3 irradiance = DecodeHDREnvironment(encodedIrradiance, unity_SpecCube0_HDR); #endif
return irradiance * occlusion * _ProbeReflectionColor * _ProbeReflectionIntensity; #else return _GlossyEnvironmentColor.rgb * occlusion; #endif }
|
该函数实际上还是采样一个引擎内置的Cubemap:unity_SpecCube0。同时,新增了BoxProjectedCubemapDirection支持,该函数使用世界空间位置和Cubemap的位置、范围对反射方向进行了校正,该算法同样可以迁移到普通的Cubemap上。urp管线内BoxProjectedCubemapDirection的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| half3 BoxProjectedCubemapDirection(half3 reflectionWS, float3 positionWS, float4 cubemapPositionWS, float4 boxMin, float4 boxMax) { // Is this probe using box projection? if (cubemapPositionWS.w > 0.0f) { float3 boxMinMax = (reflectionWS > 0.0f) ? boxMax.xyz : boxMin.xyz; half3 rbMinMax = half3(boxMinMax - positionWS) / reflectionWS;
half fa = half(min(min(rbMinMax.x, rbMinMax.y), rbMinMax.z));
half3 worldPos = half3(positionWS - cubemapPositionWS.xyz);
half3 result = worldPos + reflectionWS * fa; return result; } else { return reflectionWS; } }
|
具体算法描述可以参考文章:BoxProjectedCubemapDirection—镜面反射中的盒型投影函数。
具体效果
![]()
可以看到切换到实时反射后,水面能够显示出天空盒的颜色。
焦散
![]()
焦散用于模拟水底的焦散现象。实现焦散效果需要一张焦散模式的贴图,采样这张贴图的结果和折射效果进行结合。
实现的难点在于如何让焦散看起来在水底,而不是漂浮在水面。这可以使用水底的世界坐标xz分量去采样焦散贴图,这样焦散的效果与水底的世界空间位置就强关联,看起来与水面就会分离。
另外,焦散贴图采样时候需要往相反方向采样两次,再取两次采样的最大值作为焦散强度。这样做是为了实现焦散的来回抖动效果,如果只采样一次就是规律性移动。
具体代码:
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
| inline half3 GetCausticColor(float2 uv, float sceneDeltaDepth) { #if _CAUSTICS uv = TRANSFORM_TEX(uv, _CausticsMaskMap); half weight = saturate(1 - smoothstep(_WaterDepthAdd, _CausticsDepthCutOff + _WaterDepthAdd, sceneDeltaDepth)); half3 causticsMask1 = SAMPLE_TEXTURE2D(_CausticsMaskMap, sampler_CausticsMaskMap, uv + _Time.y * _CausticsSpeed * 0.1).rgb; half3 causticsMask2 = SAMPLE_TEXTURE2D(_CausticsMaskMap, sampler_CausticsMaskMap, uv - _Time.y * _CausticsSpeed * 0.1).rgb; return weight * _CausticsColor * _CausticsIntensity * min(causticsMask1, causticsMask2) * 10; #else return half3(0, 0, 0); #endif }
#if _CAUSTICS float2 causticsUV = input.uvAndFogAtten.xy; #if _REALTIME_DEPTH_MAP #if UNITY_REVERSED_Z float deviceDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_PointClamp, screenUV + inputData.normalWS.xz * 0.01).r; #else float deviceDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_PointClamp, screenUV + inputData.normalWS.xz * 0.01).r; deviceDepth = deviceDepth * 2.0 - 1.0; #endif causticsUV = ComputeWorldSpacePosition(screenUV + inputData.normalWS.xz * 0.01, deviceDepth, unity_MatrixInvVP).xz; #endif refractColor += lerp(1, refractColor, _CausticsRefractWeight) * GetCausticColor(causticsUV, sceneDeltaDepth); #endif
|
具体效果:
泡沫
![]()
这个效果是模拟水面的边缘部分的泡沫流动现象。主要包括泡沫的着色以及泡沫的流动,因此需要一张表示泡沫的贴图。比如下面这张贴图的g通道。
![]()
实现泡沫的流动类似云阴影的实现,泡沫本身的流动以及泡沫的边缘流动,方法是按照不同的速率采样泡沫贴图两次,然后使用PS的线性加深模式进行叠加即可。
具体代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| inline half3 GetFoamColor(float2 uv, float2 UVPanner1, float2 UVPanner2, float flowLerp, float sceneDeltaDepth) { #if _FOAM uv = TRANSFORM_TEX(uv, _FoamMap); half weight = 1 - smoothstep(_WaterDepthAdd, _FoamDepthCutOff + _WaterDepthAdd, sceneDeltaDepth); half foamWeight1 = SAMPLE_TEXTURE2D(_FoamMap, sampler_FoamMap, uv + UVPanner1 * _FoamSpeed1).g; half foamWeight2 = SAMPLE_TEXTURE2D(_FoamMap, sampler_FoamMap, uv + UVPanner2 * _FoamSpeed2).g; half foamWeight = saturate(2 * foamWeight1 + foamWeight2 - 1);
return _FoamColor * weight * foamWeight * _FoamIntensity; #else return half3(0, 0, 0); #endif }
#if _FOAM color.rgb += GetFoamColor(input.uvAndFogAtten.xy, UVPanner1, UVPanner2, flowLerp, sceneDeltaDepth); #endif
|
最后将泡沫颜色叠加到渲染结果上即可。
具体效果:
假SSS模拟
![]()
这个是对SSS的简单模拟。实现算法比较简单,根据光照方向做一定偏移作为SSS的方向,然后计算SSS方向与视线方向的夹角,以这个夹角作为因子来计算额外的SSS光强度,叠加到最终的渲染上。
具体代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| inline half3 GetSSSColor(Light light, half3 viewDirectionWS, half3 normalWS, float sceneDeltaDepth) { #if _SSS half3 sssLightDirection = normalize(light.direction + normalWS * _SSSLightDistortion); half sss = pow(saturate(dot(viewDirectionWS, sssLightDirection)), _SSSLightPower) * _SSSLightIntensity; half weight = 1 - smoothstep(_WaterDepthAdd, _SSSLighDepthCutOff + _WaterDepthAdd, sceneDeltaDepth);
return _SSSColor * light.color * sss * weight; #else return half3(0, 0, 0); #endif }
#if _SSS color.rgb += GetSSSColor(GetMainLight(), inputData.viewDirectionWS, inputData.normalWS, sceneDeltaDepth); #endif
|
具体效果:
![]()
整体效果
![]()
参考资料
Normal Blend方法总结
FlowMap技术介绍 和 案例展示
BoxProjectedCubemapDirection—镜面反射中的盒型投影函数