Featured image of post Unity-水面shader

Unity-水面shader

Unity URP 水面相关渲染

Git 相关

注意在private项目中,为了支持 LFS ,需要将pull地址设置为https的地址,而不能使用SSH地址,否则无法成功pull大文件。

URP相关

Sahder

深度纹理

按我的简单理解,深度简单意义就是指物体距离观察点的距离,在unity中也就是在camera视锥体中的物体,具体camera的距离,由于视锥体存在近裁剪平面远裁剪平面,因此在归一化的深度值中,0代表物体处于近裁剪平面,1代表物体处于远裁剪平面,0~1之间的值则代表物体在近远裁剪平面之间的距离。

深度纹理则是指一张存储屏幕不透明物体(Render Queue < 2500)的深度值的图片,Unity URP项目中,提供了获取深度纹理的相关方法。

DepthTexture

为了获取到深度纹理,首先要在 Universal Render Pipeline Asset 设置文件中开启 DepthTexture,此时就能获取到屏幕显示的深度纹理。

在URP Shader中,_CameraDepthTexture是深度纹理的属性名。使用时可以使用官方的声明文件

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"

或者自己去声明_CameraDepthTexture

TEXTURE2D(_CameraDepthTexture);
SAMPLER(sampler_CameraDepthTexture);

最常见的是在片元着色器中,通过屏幕UV去采样深度纹理,可以获得像素的深度信息

float4 depth = SAMPLE_TEXTURE2D(_CameraDepthTexture,sampler_CameraDepthTexture,screenUV);

Linear01Depth

Linear01Depth方法会返回一个线性变化的深度值,其中值的范围为[0,1]之间。0表示在最近的近裁剪平面,1表示在远裁剪平面。

LinearEyeDepth

返回观察空间中的线性的深度, 值域: [Near, Far]

利用深度重建世界坐标

unity本身提供了利用屏幕UV和深度重建像素的世界坐标的方法。

float3 worldPos = ComputeWorldSpacePosition(screenUV.xy, depth, UNITY_MATRIX_I_VP);

透明或半透明物体的深度获取

由于DepthTexture是不会采样 Render Queue >= 2500 的透明或者半透明物体,所以在片元着色器中是无法通过深度图去拿到透明物体的深度值,此时需要在顶点着色器中计算顶点在观察空间下的坐标,并将坐标传递给片元着色器,片元着色器中获取的观察空间坐标positionVS的z分量就可以看作该像素的深度值。

//urp提供了GetVertexPositionInputs方法帮助我们计算各种坐标
VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS.xyz);
OUT.positionVS = positionInputs.positionVS;

透明效果

为了实现透明效果的shader,需要在tag中需改对应的信息,此时使用该shader材质的物体的深度信息不会记录在深度纹理之中。

Tags {
	"RenderPipeline"="UniversalPipeline"
	"RenderType"="Transparent"
	"Queue"="Transparent"
}

Blend混合模式

其中一个实现透明效果的方式就是Blend,这决定了片段结果如何与相机颜色目标/缓冲区中现有的值相结合。

//etc 传统透明度混合
Blend SrcAlpha OneMinusSrcAlpha

具体的blend参数可以参考Blend官方文档
声明了blend模式之后,只要在片元着色器中对alpha值进行修改,就能改变物体的透明度。(目前只了解到这里,只是非常简单的运用,之后拓展之后再补充)

OpaqueTexture

Opaque Texture 在 URP 渲染任何透明网格之前立即提供场景的快照。也就是说在渲染透明物体之前,先将渲染出的不透明物体的图存储在 _CameraOpaqueTexture 中。在shader中,使用 _CameraOpaqueTexture 前需要先声明属性。

TEXTURE2D(_CameraOpaqueTexture);
SAMPLER(sampler_CameraOpaqueTexture);

在片元着色器中,通过屏幕UV对纹理进行采样,就可以获得不包含透明物体的场景图片。可以通过这个纹理,制作诸如玻璃、水面等现实中存在透明效果的材质。

但是如果想要对物体进行正反面都渲染,不进行剔除的话,还是只能使用blend混合模式,不然渲染出来的反面会存在不正确的效果。


海面效果

水体渲染的大部分都是参考自BoatAttack_水效果分析。同时也是对官方urp的项目的一种解读。

海面波浪

正弦波(Sine Wave)

这一部分主要是参考了知乎文章,具体看文章,这里只写一些心得了。

波浪的一种简单实现方法是 sin 波叠加,利用了sin 函数周期循环波动的特性来模拟波浪。波形可以看这里

$$A\sin(kx - wt)$$

其中 A 代表了振幅,也就是波浪的高度,k 代表了波浪的频率, w 代表了波浪的速度,t 在这指的是时间的变化。为了方便调整,我们将 k 值用另一种形式来表现,其中l表示浪的频率。

$$k = \frac{2\pi}{l} $$

在改变顶点的同时也要重算法线,不然在场景中的光照表现就会有问题。法线N 的计算是根据 副切线B 和 切线T 叉乘得到的。

$$N = B\times T$$

其中 切线T 就是函数上该点的斜率,可以通过对函数求导得到,副切线则是指定副切线B的方向为一个固定矢量(0,0,1),这里能够指定副切线向量的原因是我们还是二维的实现,没有改变Z轴上的坐标。具体在上面贴的连接中有详细的解释。此时还没有引入z轴的变换,引入z轴的变换将在 Gerstner Wave 章节讲。

正弦波叠加后的波浪比较平滑,没有浪尖,是比较简单的实现方法。

Gerstner Wave

$$ (x + a\cos (kx),a\sin (kx) ) $$

以上是 Gerstner Wave 的数学公式,相较于圆的数学公式多了在x轴上的变化(公式)。我们将k值也按照上述方式处理,使其更方便控制。

在公式网页中调整参数时,可以发现当a值超过一定数值时,波形会出现自相交的现象,为了避免这个现象,通过参考网址的推导,可以得出最终限制自相交的条件。

$$ 0 < ak < 1 $$

因此我们将a的值换一种表达方式,同时限制s的值在0 ~ 1 之间,以防止自相交现象的发生。

$$ a = \frac{s}{k},s \in \left [ 0,1 \right ] $$

上面的计算还是在2维坐标下,现在让我们引入z轴,实现在三维坐标下的波形。这一部分暂时理解不了,只能说再去看看知乎文章上的推导,大佬写的很详细。同样的还有法线的计算,因为这次引入了z轴的变换,所以副切线不能再假设成(0,0,1),而是要根据现有的条件去推导,相关推导方式也同样去看知乎文章。等之后看懂了再补充进来,不过法线N的计算和正弦波的法线计算本质相同,都是用的同一个公式,然后切线和副切线也是根据不同的变量去取他的导数。

最后将计算过程转化为代码

GerstnerData GerstnerWave(float3 positionWS, vector direction ,float waveCount,float stepnessMax,float stepnessMin,float wavelengthMax,float wavelenthMin, float randomdirection) {
    GerstnerData data;
    data.positionWS = float3(0,0,0);
    data.binormal = float3(0,0,0);
    data.tangent = float3(0,0,0);
    float3 P;
    float3 B;
    float3 T;
    for(int i = 0; i < waveCount; i++) {
    	float step = (float) i / (float) waveCount;
    	step = pow(step, 0.75f);
    	float s = lerp(stepnessMax,stepnessMin,step) / waveCount ;
    	float l = lerp(wavelengthMax,wavelenthMin,step) ; 
    	float k = 2 * PI / l;
    	float g = 9.81f;
    	float w = sqrt(g * k);
    	float a = s / k;
    	float2 d = float2(Random(i),Random(2*i));
    	d = normalize(lerp(normalize(direction.xy), d, randomdirection));

    	float2 waveVector = k * d;
    	float value = dot(waveVector , positionWS.xz) - w * _Time.y;

    	P.x += d.x * a * cos(value);
    	P.y += a * sin(value);
    	P.z += d.y * a * cos(value);

    	B.x += d.x * d.y * a * k * -sin(value);
    	B.y += d.y * a * k * cos(value);
    	B.z += d.y * d.y * a * k * -sin(value);

    	T.x += d.x * d.x * a * k * -sin(value);
    	T.y += d.x * a * k * cos(value);
    	T.z += d.x * d.y * a * k * -sin(value); 
    }
    data.positionWS.x = positionWS.x + P.x;
    data.positionWS.y = positionWS.y + P.y;
    data.positionWS.z = positionWS.z + P.z;
    data.binormal = float3(B.x,B.y,1 + B.z);
    data.tangent = float3(1 + T.x,T.y,T.z);
    return data;
}

反射(Planar Reflection)

反射效果的实现使用的是Planar Reflection

反射效果这次是选择通过在场景中新建反射相机,然后将当前相机的世界坐标乘以反射矩阵,实时计算反射相机的坐标,将反射坐标拍摄的图存储到Render Texture中并传递给shader。然后在shader中通过屏幕UV采样传递过来的RT图,来实现水面的反射效果。

在c#中,我们通过计算反射矩阵,与主相机的世界坐标相乘,得到反射相机的位置,其中反射矩阵的推导主要参考csdn反射文章,不过我没有理解这部分的推导,直接拿对方的结果来使用了,之后的学习中再对这部分进行深入。

用上述的方法实现的反射会出现当物体移动到反射平面下时,也会出现反射的现象,此时需要进行斜视锥体裁剪,unity官方提供了API去处理这部分问题。实际原理也可以看上面的csdn文章中的推导。

public Matrix4x4 CalculateObliqueMatrix(Vector4 clipPlane);

反射还有很多实现方法,诸如SSR,反射探针等,之后可以拓展一下。

菲涅尔现象

反射同时还要遵循菲涅尔现象。在日常中,我们观察水面的时候,离自己越近的水面通常反射效果越不明显,越能看到水下的样子,而离自己越远的水面越会反射水面上的景色。

我们可以通过世界空间下的法线以及世界空间下的视线向量来计算菲尼尔现象的值,然后用计算出来的值乘以采样后的反射信息,实现菲涅尔现象。代码计算菲涅尔如下。

half CalculateFresnelTerm(half3 normalWS, half3 viewDirectionWS)
{
  return pow(1.0 - saturate(dot(normalWS, viewDirectionWS)), _FValue);
}

高光(BDRF)

高光直接调用URP本身提供的计算BDRF光照的代码,在之后的光照学习中再进行拓展。

//高光 
BRDFData brdfData;
half alpha = 1;
InitializeBRDFData(half3(0,0,0),0,half3(1,1,1),0.95,alpha,brdfData);
half3 spec = DirectBDRF(brdfData, normalWS, light.direction, viewDirWS) * light.shadowAttenuation * light.color  * _lightValue;

水面下

折射

水面下折射

水体的折射效果是通过对屏幕UV进行扰动,并对_CameraOpaqueTexture进行采样,形成折射的效果。

float2 uvTest = AlignWithGrabTexel(screenUV + uvOffset);
float3 col2 = SAMPLE_TEXTURE2D(_CameraOpaqueTexture,sampler_CameraOpaqueTexture,uvTest).rgb;

但是如果只是简单的根据屏幕UV对_CameraOpaqueTexture进行采样的话,会出现水面上的物体也被UV扰动影响的现象。此时我们要利用深度信息,判断物体是否在水面下,水面下才进行UV扰动,水面上的则按原来的屏幕UV去采样。首先计算水面下物体的深度值(这里的值可以看作水面下视线的碰撞点到camera的距离),然后通过像素在屏幕坐标的z值,使用unity提供的宏UNITY_Z_0_FAR_FROM_CLIPSPACE(可以理解为水面到camera的距离),将把裁剪空间下的z值转换到[0, 1]。通过以上两个值去计算深度差,从而判断像素是否在水面之下。在水面之下进行uv偏移,水面之上则用原来的uv。

float2 uvTest = screenUV + uvOffset;
float backgroundDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,sampler_CameraDepthTexture, uvTest),_ZBufferParams);
float surfaceDepth = UNITY_Z_0_FAR_FROM_CLIPSPACE(IN.screenPos.z);
//这个是视觉空间下的水面下深度
float depthDifference = backgroundDepth - surfaceDepth;
uvTest = scrPos + uvOffset * saturate(depthDifference);
float3 col2 = SAMPLE_TEXTURE2D(_CameraOpaqueTexture,sampler_CameraOpaqueTexture,uvTest).rgb;

此时可以看到水面上的物体不会再被折射,但是这又衍生出新的问题,水面上的物体虽然不会被UV扰动了,但是在物体周围会出现明显的细线,也就是伪像,细线会被UV扰动。我们将采用困难的方式来解决问题,将 UV 乘以纹理大小,丢弃小数部分,偏移到纹素中心,然后除以纹理大小。

float2 AlignWithGrabTexel (float2 uv) {		
  return float2(floor(uv * _CameraDepthTexture_TexelSize.zw) + 0.5) * abs(_CameraDepthTexture_TexelSize.xy);
}

float3 viewNormal = mul((float3x3)GetWorldToHClipMatrix(),-IN.normalWS).xyz;
float2 uvOffset = viewNormal.xz * 0.2 * _DistoritionValue / IN.screenPos.w;
//使用AlignWithGrabTexel解决边缘线条伪像的问题
float2 uvTest = AlignWithGrabTexel(scrPos + uvOffset);
float backgroundDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,sampler_CameraDepthTexture, uvTest),_ZBufferParams);
float surfaceDepth = UNITY_Z_0_FAR_FROM_CLIPSPACE(IN.screenPos.z);
//这个是视觉坐标下的水面下深度
float depthDifference = backgroundDepth - surfaceDepth;
uvTest = AlignWithGrabTexel( scrPos + uvOffset * saturate(depthDifference));
float3 col2 = SAMPLE_TEXTURE2D(_CameraOpaqueTexture,sampler_CameraOpaqueTexture,uvTest).rgb;

有时候会出现使用这个方法处理伪像的时候,伪像依旧存在的问题,这是因为MSAA抗锯齿导致的,使用上述方法处理伪像问题时,要关闭 Universal Render Pipeline Asset 中的MSAA抗锯齿功能,上述的处理在MSAA抗锯齿环境下也会失效。

水体本身颜色的吸收以及水深导致的颜色变化

具体可以参考BoatAttack_水效果分析

海洋本身会反射蓝色波长的光线,为了模拟这个效果,我们使用两个ramp图去分别控制水体根据深度变化的颜色和水体受光自身发出的颜色。

使用上面计算出的UV扰动,对深度纹理重新采样,并按照上面的过程重新计算深度差值。利用深度差值去采样我们的ramp图来得到水体本身颜色。

backgroundDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,sampler_CameraDepthTexture, uvTest),_ZBufferParams);
depthDifference = backgroundDepth - surfaceDepth ;
float3 rampValue = SAMPLE_TEXTURE2D(_Ramp,sampler_Ramp,float2(depthDifference * 0.2,0)).rgb;
float3 ramp2Value = SAMPLE_TEXTURE2D(_Ramp2,sampler_Ramp2,float2(depthDifference * 0.2,0)).rgb;

half shadow = light.shadowAttenuation;
half3 GI = SampleSH(IN.normalWS);
half3 sss = 1 * (shadow * light.color + GI);
sss *= ramp2Value;
col2 = col2 * rampValue;

焦散

参考文章

光线经过水面时,受到水面波浪和折射的影响,在水底会出现焦散现象,在本项目中,我们使用焦散贴图模拟水底焦散,在片元着色器中重建世界坐标,然后使用像素的世界坐标的xz轴对焦散图片进行采样,然后添加uv扰动和rgb分离使得焦散更加真实。最后根据深度值来控制焦散显示的范围。

首先是重建世界坐标,利用unity提供发api活得像素的直接坐标,,然后使用世界坐标的xz轴的值,并且加上uv偏移实现uv扰动,最后对焦散贴图进行采样,采样的结果最后要乘以光线的颜色。

float2 causticUV = (worldPos.xz + _Speed * _Time.x) * _CausticValue +  uvOffset * 2 ;//+  uvOffset*_Time.w ;
SAMPLE_TEXTURE2D(_CausticColor, sampler_CausticColor, causticUV )  * light.color;

这里我们使用两个uv偏移值来得到两个不同的UV坐标,分别对焦散贴图进行采样,然后取采样后的最小值。这样模拟焦散波动闪烁的效果。

float2 causticUV = (worldPos.xz + _Speed * _Time.x) * _CausticValue +  uvOffset * 2 ;
float2 causticUV2 = (worldPos.xz * float2(1,-1) + _Speed * _Time.x * 2.0) * _CausticValue +  uvOffset ;
float3 caustic = SAMPLE_TEXTURE2D(_CausticColor, sampler_CausticColor, causticUV )  * light.color;
float3 caustic2 = SAMPLE_TEXTURE2D(_CausticColor, sampler_CausticColor, causticUV2 )  * light.color;
col2 += min(caustic , caustic2);

此时已经初具雏形,这里再加上RGB通道分离,使得焦散能更加真实,RGB通道分离其实就是对R、G、B通道分别进行采样,采样中对uv进行不同的偏移,使得采样出的RGB位置有一定的偏差,然后再组合起来。

float3 sampleCausticRGB(float2 uv,float splitRGB)
{
  float r = SAMPLE_TEXTURE2D(_CausticColor, sampler_CausticColor, (uv + float2(splitRGB,splitRGB)) ).r;
  float g = SAMPLE_TEXTURE2D(_CausticColor, sampler_CausticColor, (uv + float2(splitRGB,-splitRGB)) ).g;
  float b = SAMPLE_TEXTURE2D(_CausticColor, sampler_CausticColor, (uv + float2(-splitRGB,-splitRGB)) ).b;
  return float3(r,g,b);
}

接着计算焦散显示的范围,通常越靠近岸边焦散越可见,水越深越难看到。这里之后可以尝试用世界坐标来计算。

backgroundDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,sampler_CameraDepthTexture, scrPos),_ZBufferParams);
depthDifference = backgroundDepth - surfaceDepth ;
half depthEdge = saturate(IN.positionWS.y * 20 + 1);
half edgeFoam = saturate(1 - (depthDifference) * _FoamEdge ) * _FoamValue;

最后再结合上面计算出的各种影响因子,输出最终的焦散效果。值得注意的是这种方式实现的焦散只能通过水面往下的视角才能看到,一旦camera在水面之下则无法再观察到,另一种直接在水面下的面实现焦散的方式可以参考水泡沫和焦散效果

浪花

浪花同样也是用浪花贴图去模拟水面浪花,利用屏幕UV去采样浪花贴图,然后根据深度去确定浪花显示范围。

float2 foamUV = IN.positionWS.xz + 0.6 * _Time.x + normalWS.xz *0.09 + uvOffset
float3 foamR = SAMPLE_TEXTURE2D(_FoamTex,sampler_FoamTex,foamUV).rrr;
float3 foamG = SAMPLE_TEXTURE2D(_FoamTex,sampler_FoamTex,foamUV).ggg;
float3 foamB = SAMPLE_TEXTURE2D(_FoamTex,sampler_FoamTex,foamUV).bbb;
float3 foamColor = saturate(foamR * 0.1 + foamG * 0.2 + foamB * 0.7) ;
float3 edgerusult = saturate(foamColor * edgeFoam);

这里对浪花贴图分别采样是因为这次使用的浪花贴图的RGB通道分别存储了波浪程度不同的浪花图,我们分别采样并按一定比例混合,然后乘以边缘显示数值,得到最终的浪花效果。

这里的浪花效果还非常简单,并且没有实现岸边波浪的效果,之后需要拓展一下,