找回密码
 立即注册
查看: 408|回复: 1

[笔记] Unity 实现真实感水体渲染(基于URP)

[复制链接]
发表于 2022-4-20 07:31 | 显示全部楼层 |阅读模式
效果视频:
[技术美术] 真实感水体渲染_哔哩哔哩_bilibili
水体深浅区域




深浅区域 Mask

为了得到深浅区域,需要获取当前相机的深度纹理(不透明物体的深度值)和水面(透明物体)的深度值,计算深度差,通过以下几步完成。
开启深度纹理

在 URP 管线中,在管线配置文件的 General 层级中开启深度纹理



URP 管线配置

深度纹理 Depth Texture 打开后可以在 Frame Debug 中看到,在绘制不透明物体(DrawOpaqueObjects)后多出了 CopyDepth ,这一步就是将相机的深度纹理缓存。
另外,Opaque Texture 对应了 CopyColor 这一步,将已绘制的不透明物体的缓存,这个也要打开,在后面会用到。
Shdaer Graph 中使用深度纹理




Shader Graph 中使用深度纹理

调用深度纹理对应了 Shader Graph 中的 Scene Depth 节点,节点的 Depth Sampling modes 选择 Eye ,使用视空间的深度值 z ,而 Screen Position 的 w 分量是视空间的 z 分量(通过透视矩阵乘法可以证明),保证两者的同时是视空间 z 轴坐标值的情况下,两者之差就是在视空间下的深度差。
其中 Depth 是一个 Float 属性,用来控制深浅区域范围。
相关原理可以参考深度纹理和屏幕空间的文章:
王二:Unity 深度纹理(Depth Texture)和屏幕空间坐标(Screen Position)
深浅区域颜色

将上一步得到的 Mask 进行 Clamp 后用于 Lerp 颜色插值,加入 Strength 变量调节边缘的软硬程度



深浅区域颜色 Lerp

得到当前的效果



深浅区域颜色

水面流动效果

目前的水面看起来完全没有液体的感觉,接下来给它加入一些液体的流动感。
Flowmap 基本概念

方法基于这篇教程:Texture Distortion
简单概括 flowmap 就是一种连续且重复的扰动 uv 的技巧,形成流动的感觉,这个技巧的高明之处在于:使重复且有周期性断点的扰动看起来是随机又连续的
这个方法的介绍来源于 SIGGRAPH 2010 年《传送门 2》团队 Alex Vlachos 的分享:
https://www.slideshare.net/alexvlachos/siggraph-2010-water-flow-in-portal-2

基本思路可以概括为:对 uv 进行周期性的扰动,也就是 uv 根据噪声朝各个方向做不同程度的偏移,形成流动效果,但这种流动是以周期重复的,为了消除明显的周期性现象,可以再做一次采样,按错峰的方式混合两次采样结果的,这两次采样的周期是波峰波谷交错的,这样在每次周期过渡时都被淡入或淡出,就不容易看出周期重复的现象。



周期相位图

主要代码:
void getFlowNormal_float(float2 uv, UnityTexture2D flowTex, UnityTexture2D normalTex, float4 normalTex_ST,
     float speed,
     out float3 flowNormal) {
    uv *= normalTex_ST.xy;
    float4 v_flowTex = SAMPLE_TEXTURE2D(flowTex.tex, flowTex.samplerstate, uv) * 2.0 - 1.0;
    float2 flow_dir = v_flowTex.xy;  // uv 扰动方向向量

    float phase0 = frac(_Time.x * speed);
    float phase1 = frac(_Time.x * speed + 0.5);  // +0.5 使两个相位的波峰波谷交错出现

    float2 uv_st = uv * normalTex_ST.xy + normalTex_ST.zw;

    float4 tex0 = SAMPLE_TEXTURE2D(normalTex.tex, normalTex.samplerstate, uv_st - phase0 * flow_dir);
    float4 tex1 = SAMPLE_TEXTURE2D(normalTex.tex, normalTex.samplerstate, uv_st - phase1 * flow_dir);

    float flowLerp = abs(1 - 2 * phase0);
    flowNormal = lerp(tex0, tex1, flowLerp);
}

代码中可以看出,扰动是两次采样交替使用的,frac 使得相位是周期为 1 秒的锯齿波,并且第二个相位偏移 0.5 秒使它们扰动的周期错开。



flowmap1

暂时用比较好看出效果的方格贴图,上图的 shader graph 和 贴图参数如下



flowmap1 - shadergraph

Flowmap 优化

上图的效果可以看到明显的过渡,感觉不连续而且重复,原因一方面是我故意选用了容易看出这些问题的贴图,另一方面是存在可以继续优化的地方。
优化后代码:
void getFlowNormal_float(float2 uv, UnityTexture2D flowTex, UnityTexture2D normalTex, float4 normalTex_ST,
     float speed,
     out float3 flowNormal) {
    uv *= normalTex_ST.xy;
    float4 v_flowTex = SAMPLE_TEXTURE2D(flowTex.tex, flowTex.samplerstate, uv) * 2.0 - 1.0;
    float2 flow_dir = v_flowTex.xy;  // uv 扰动方向向量
    float noise = v_flowTex.a;

    float phase = _Time.x * speed;
    float phase0 = frac(phase + noise);
    float phase1 = frac(phase + 0.5 + noise);  // +0.5 使两个相位的波峰波谷交错出现

    float2 uv_jump = float2(0.25, 0.1);
    float2 phase0_jump = (phase - phase0) * uv_jump;
    float2 phase1_jump = (phase - phase1) * uv_jump;

    float2 uv_st = uv * normalTex_ST.xy + normalTex_ST.zw;

    float2 uv0 = uv_st - phase0 * flow_dir + phase0_jump;
    float2 uv1 = uv_st - phase1 * flow_dir + phase1_jump;

    float4 tex0 = SAMPLE_TEXTURE2D(normalTex.tex, normalTex.samplerstate, uv0);
    float4 tex1 = SAMPLE_TEXTURE2D(normalTex.tex, normalTex.samplerstate, uv1);

    float flowLerp = abs(1 - 2 * phase0);
    flowNormal = lerp(tex0, tex1, flowLerp);
}
做了两个地方的优化

  • 相位加入噪声
用 flowmap 贴图的 a 通道存储噪声,对相位做扰动,让过渡不重复得太明显



相位噪声


  • 相位加入跳跃
让每个周期后的 uv 扰动偏移一个数值,可以延长这个周期,延长倍数是uv各自偏移周期的最小公倍数,例如 u 偏移 0.25,v 偏移 0.1,周期分别为 4 和 10,最小公倍数是 20,也就说原本 1 秒便重复的动画现在 20 秒才会重复。但在实际使用中,效果并不那么明显,可能是精度的原因。



flowmap2

这个图还是可以明显看到周期过渡的,但换成噪声类的就不那么明显了。
Flowmap 扰动法线

将贴图替换为法线,这里转换到世界空间,方便后面的法线之间做融合



Flowmap 法线扰动 shader graph

其中法线强度应该考虑到深浅,所以用水面深度的mask进行插值。

当前代码:
inline float3x3 TBN() {
    float3 T = float3(1, 0, 0);
    float3 B = float3(0, 0, 1);
    float3 N = float3(0, 1, 0);
    float3x3 TBN = float3x3(T, B, N);
    return TBN;
}


void getFlowNormal_float(float2 uv, UnityTexture2D flowTex, UnityTexture2D normalTex, float4 normalTex_ST,
     float speed, float normalInt,
     out float3 flowNormal) {
    uv = uv * normalTex_ST.xy + normalTex_ST.zw;
    float4 v_flowTex = SAMPLE_TEXTURE2D(flowTex.tex, flowTex.samplerstate, uv) * 2.0 - 1.0;
    float2 flow_dir = v_flowTex.xy;  // uv 扰动方向向量
    float noise = v_flowTex.a;

    float phase = _Time.x * speed;
    float phase0 = frac(phase + noise);
    float phase1 = frac(phase + 0.5 + noise);  // +0.5 使两个相位的波峰波谷交错出现

    float2 uv_jump = float2(0.25, 0.1);
    float2 phase0_jump = (phase - phase0) * uv_jump;
    float2 phase1_jump = (phase - phase1) * uv_jump;

    float2 uv_st = uv * normalTex_ST.xy + normalTex_ST.zw;

    float2 uv0 = uv_st - phase0 * flow_dir + phase0_jump;
    float2 uv1 = uv_st - phase1 * flow_dir + phase1_jump;

    float3 tex0 = UnpackNormalScale(SAMPLE_TEXTURE2D(normalTex.tex, normalTex.samplerstate, uv0), normalInt);
    float3 tex1 = UnpackNormalScale(SAMPLE_TEXTURE2D(normalTex.tex, normalTex.samplerstate, uv1), normalInt) ;

    float3 normalWS_0 = normalize(mul(tex0, TBN()));
    float3 normalWS_1 = normalize(mul(tex1, TBN()));

    float flowLerp = abs(1 - 2 * phase0);
    flowNormal = normalize(lerp(normalWS_0, normalWS_1, flowLerp));
}当前效果



flowmap3

虽然 FlowMap 写了这么多,但后来觉得 FlowMap 在海浪的作用下最终效果不明显,就没有使用,可能这种技术更时候比较平缓的水面,不太适合有波涛的海面。
水面波形

除了水面的涟漪,还需要让水面有较大的波动,比如海浪,这种形变通过按照波形改变顶点位置来实现。
Sine Wave

正弦波可能是最基本的模拟海浪形状的波形了,用于改变顶点位置,我们需要计算它在各个方向上的顶点位移和法线。



Sine Wave


  • 顶点位移
x、z 轴代表水平方向,不发生位移,y 轴代表垂直方向




    • —— 振幅
    • —— 波长
    •   ——   频率
    •   —— 时间
    •   —— 速度
    •   —— 沿 x 轴,z 轴移动方向的单位向量



  • 法线计算
法线可以由副法线和切线的叉乘得到,水面是三维空间中的曲面,切线沿 x 轴,副法线沿 z 轴,于是可以分别看成曲面对 x、z 的偏导数。


    • 曲面方程





    • 切线向量





    • 副法线向量





    • 法线向量



多个波融合对于顶点和法线一样,都只需要简单的求和即可。

  • Sine wave 波形改进
sin 波看起来太圆滑了,现实中的水波,由于重力的作用,应该是波峰比较尖,波谷比较平,对 sin 波进行一些操作让其形状更符合现实。



sine wave 波形改进

这个数值操作很简单,就是将其从 [-1, 1] 移动到 [0, 2] 然后做 k 次幂计算,类似调整高光范围的手法,让[0,1]之间的数更小,1 以上的数更大。
但是这个波峰还是挺圆的,也许适合小型的水面,但不适合海浪。
Gerstner Wave

Gerstner Wave 除了垂直方向,对于水平方向也产生位移,从而实现更接近现实的在波峰聚拢,在波谷散开的形状。



Gerstner Wave


  • 顶点位移













    •   —— 曲面方程(多个波融合,i 为索引)
    •    —— x,y,z 轴偏移增量
    •   —— 坡度(超过 会使切线 x 轴为负数,波峰断开)
    • —— 振幅
    • —— 波长
    •   ——   频率
    •   —— 时间
    •   —— 速度
    •   —— 沿 x 轴,z 轴移动方向的单位向量



  • 法线计算
计算过程和 sine wave 类似,这里直接写出多个波融合的结果







Gerstner Wave 代码:
void GerstnerWave_float (
    float4 waveDir, float4 waveParam, float3 p, out float3 delta_pos, out float3 delta_normalWS
) {
    // waveParam : steepness, waveLength, speed, amplify
    float steepness = waveParam.x;
    float wavelength = waveParam.y;
    float speed = waveParam.z;
    float amplify = waveParam.w;
    float2 d = normalize(waveDir.xz);

    float w = 2 * 3.1415 / wavelength;
    float f = w * (dot(d, p.xz) - _Time.y * speed);
    float sinf = sin(f);
    float cosf = cos(f);

    steepness = clamp(steepness, 0, 1 / (w*amplify));

    delta_normalWS = float3(
        - amplify * w * d.x * cosf,
        - steepness * amplify * w * sinf,
        - amplify * w * d.y * cosf
    );

    delta_pos = float3(
        steepness * amplify * d.x * cosf,
        amplify * sinf,
        steepness * amplify * d.y * cosf
    );
}
用一个 SubGraph 混合4个波



MultiGerstnerWave



叠加多个 Gerstner 波

泡沫

为了增加海浪的真实感,应该伴有泛白的泡沫,也就是浪花,这里实现两种基本的泡沫效果。
波峰泡沫

根据高度得到波峰 Mask,然后赋予泡沫贴图



波峰泡沫 Shader Graph 1



波峰泡沫 Shader Graph 2



波峰泡沫效果图

近岸泡沫

近岸的泡沫稍微复杂一些,首先需要根据深度图抠出近岸区域,然后为了与海浪的频率尽量保持一致,用一个 sin 周期函数,并用一个频率参数来控制泡沫区域的范围和频率



近岸泡沫 Shader Graph



近岸泡沫效果图

平面反射

水面的镜面反射可以清晰的倒影出岸上的景象,通过平面反射技术可以实现,具体原理参考:王二:Unity 实现平面反射(基于 URP)



平面反射效果

当前的水面反射太过清晰平静,显得不真实,水面的形变应该对反射图像产生扰动,所以加入世界空间法线 xz 方向的扰动 uv



平面反射 Shader Graph



平面反射加入水面扰动

水下折射

对于近海岸区域应当可以看见水下的物体,并且模拟折射的效果,这里使用 Opaque Texture,并将水面设置为透明物体,渲染队列中为 3000,Opaque Textue 会将渲染队列中在水面之前的所有不透明物体渲染结果缓存,这样就能看见水底了,对于折射的效果只要扰动这张贴图的采样 uv 就可以实现。
最后将折射和反射结果根据深浅区域 Lerp 得到水面深浅区域的颜色。



水下折射 Shader Graph 1



水下折射 Shader Graph 2



水下折射效果图

水下焦散

深度纹理重建世界空间坐标

焦散是由于水面的不平整导致光线的折射在水底不均匀的汇聚或散开,模拟焦散效果需要将贴图贴在水下的物体表面,这就需要通过深度纹理重建的世界空间坐标了。深度纹理重建世界坐标有很多计算方式,这里使用的方法原理可参考这篇帖子:Help Wanted - Computing World Position from Depth
深度纹理重建的世界空间坐标的原理如下:



深度纹理重建的世界空间坐标

例如通过深度纹理获得 P 点世界空间坐标,使用深度纹理可获得视空间下的 z 轴坐标值 depth,原理可参考:王二:Unity Shader Graph 中深度纹理(Depth Texture)和屏幕空间坐标(Screen Position), 点为相机位置,   为相机指向  点的 z 值为 1 的向量,那么   点坐标就可以表示为

剩下的问题就是求出向量   。现在已知单位向量    代表了相机朝向方向,在视空间下坐标是   ,单位向量   是 点指向相机的单位向量,那么世界空间下的  可以表示为




结合上面两式可以得到


上面计算向量可以直接都用世界空间下的,而 depth 用视空间下的,因为矩阵变换并不影响向量的缩放。



深度纹理重建世界坐标 Shader Graph

焦散计算

有了深度纹理重建的世界坐标后,用 xz 坐标采样焦散贴图,再加入法线扰动,就可以实现焦散效果了,最终将焦散和水下折射结合,作为浅水区域颜色。



焦散计算 Shader Graph



焦散融合水下折射



焦散效果

波光粼粼

最后我希望让远处的水面产生的高光有一种波光粼粼的效果,简单的计算一个高光,并使用距离将近处的高光 Mask 掉。



高光 Shader Graph



波光粼粼

最终效果




最终效果静帧

本文记录了我使用 Unity 的 URP 管线制作真实感水体的一些大致步骤,主要是个人的学习笔记,如果能给其他人提供参考就更好了。文章可能有错误,如果有发现请帮忙指出,谢谢!

参考

Making a Water Shader in Unity with URP! (Tutorial)
《Unity Shader 入门精要》
Texture Distortion
毛星云:真实感水体渲染技术总结
aleanna:Unity URP真实感水体渲染工具(一)水的形态,折射,着色与简单交互
Chapter 1. Effective Water Simulation from Physical Models
How to create an Ocean Shader with Shader Graph in Unity
Help Wanted - Computing World Position from Depth

本帖子中包含更多资源

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

×
发表于 2022-4-20 07:37 | 显示全部楼层
顶[蹲]
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

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

GMT+8, 2024-4-28 22:42 , Processed in 0.330099 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

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