Unity Shader 水面 Shader 教程 从 Gerstner 波浪到屏幕折射、焦散、泡沫的完整实现

01 总览

Shader 全景架构

使用 Unlit + Transparent 渲染模式,通过屏幕空间技术(折射、深度重建)实现高质量水面效果。

Gerstner 波浪

3 层 Gerstner 波叠加,顶点位移 + 法线计算,水面有真实起伏

屏幕空间折射

采样 _CameraOpaqueTexture,法线驱动偏移,RefractionMask 防穿帮

焦散

双纹理 Min 混合,基于折射后水底位置同步,深度衰减

深浅水色 + 吸收

exp2 深度衰减控制深浅色过渡,AbsorbStrength 控制水色覆盖

平面反射

采样 _ReflectionTex,Fresnel 控制反射强度

泡沫 + 岸边

Simplex 噪声 + 正弦波泡沫,岸边浅色着色 + 边缘高光

02 参数

属性参数总表

Shader 共有 30+ 个属性,分为 7 组:基础水色、法线、反射、折射、焦散、岸边/泡沫、波浪。

属性名 类型 默认值 作用
_ShallowRange Range(0,10) 0.13 浅水深度范围,控制深浅色过渡
_DeepColor Color (0,0.21,0.4,1) 深水颜色
_ShallowColor Color (0.57,0.87,1,0.7) 浅水颜色
_FresnelPower Float 10.61 水色→反射的 Fresnel 指数
_NormalMap 2D bump 法线贴图
_NormapTiling Float 1 法线平铺
_NormalSpeed Vector (0.1,0.1,0,0) 法线滚动速度
_NormalScale Range(0,1) 0.06 法线强度(很小 → 微妙波纹)
_ReflectionTex 2D white 平面反射渲染纹理(脚本注入)
_ReflectionFresnelParam Vector (0,1,5,0) 反射 Fresnel 参数 (offset, scale, power)
_RefractionIntensity Range(0,2) 0.18 折射偏移强度
_RefractionMask_FadeStart Float 0 折射 Mask 平滑起始
_RefractionMask_FadeRange Range(0,1) 0.55 折射 Mask 过渡范围
_CausticesMap 2D white 焦散纹理
_CausticesTiling Range(0.2,1) 1 焦散平铺
_ShallowRange1 Range(0,10) 6.76 焦散深度衰减范围
_CausticsShoreFade Range(0.001,1) 0.536 焦散岸边淡出距离
_AbsorbStrength Float 3.78 水色吸收强度(越大越快不透明)
_ShoreRange Float 0.13 岸边着色范围
_ShoreColor Color (0.57,1,0.99,0) 岸边浅色
_ShoreEdgeWidth Range(0,1) 0.22 岸边高光宽度
_FoamRange Float 0.1 泡沫深度范围
_FoamFrequency Float 15.6 泡沫正弦频率
_WaveA/B/C Vector4 --- Gerstner 波参数 (速度XY, 陡度, 波长)

03 顶点阶段 Vertex

Gerstner 波浪

水面不是平面 ------ 顶点阶段使用 3 层 Gerstner 波叠加,产生真实的波浪起伏和法线。每层波有独立的方向、陡度和波长。

核心代码

cs 复制代码
// 单层 Gerstner 波:返回位移,同时累加切线/副切线
float3 GerstnerWave(float3 position, inout float3 tangent, inout float3 binormal, float4 wave)
{
    const float pi = 3.1415926;
    float steepness = wave.z * 0.01;
    float wavelength = wave.w;
    float k = 2.0 * pi / wavelength;       // 波数
    float c = sqrt(9.8 / k);             // 波速 √(g/k)
    float2 d = normalize(wave.xy);      // 波方向
    float f = k * (dot(d, position.xz) - c * _Time.y); // 相位
    float a = steepness / k;            // 振幅

    // 累加切线和副切线(用于叉积求法线)
    tangent  += float3(-d.x*d.x*(steepness*sin(f)), d.x*(steepness*cos(f)), -d.x*d.y*(steepness*sin(f)));
    binormal += float3(-d.x*d.y*(steepness*sin(f)), d.y*(steepness*cos(f)), -d.y*d.y*(steepness*sin(f)));

    return float3(d.x*(a*cos(f)), a*sin(f), d.y*(a*cos(f)));
}

// 3 层波叠加 → 位移 + 法线
void ApplyGerstnerWaves(float3 positionWS, out float3 displacedWS, out float3 normalWS)
{
    float3 tangent = float3(1,0,0);
    float3 binormal = float3(0,0,1);
    float3 offset = 0;
    offset += GerstnerWave(positionWS, tangent, binormal, _WaveASpeedXYSteepnesswavelength);
    offset += GerstnerWave(positionWS, tangent, binormal, _WaveB);
    offset += GerstnerWave(positionWS, tangent, binormal, _WaveC);
    displacedWS = positionWS + offset;
    normalWS = normalize(cross(binormal, tangent)); // 法线 = cross(副切线, 切线)
}

关键点

顶点位移后的新位置需要 TransformWorldToObject 转回 OS,再走标准 URP GetVertexPositionInputs,确保 positionCS 正确。原始 OS 位置被保存到 positionOSOriginal,用于折射 Mask 的深度比较。

04 片元阶段 Fragment

法线贴图双滚动混合

水面法线由两层法线贴图以不同速度和方向滚动、混合而成,模拟细密波纹。法线驱动折射偏移和反射 UV。

cs 复制代码
// 双层法线滚动
float2 baseUV = positionWS.xz * _NormapTiling;
float2 uvA = baseUV + _NormalSpeed * _Time.y;                          // 正向
float2 uvB = baseUV * 0.5 + (_NormalSpeed * float2(-0.5, -0.5)) * _Time.y; // 半速反向

float3 nA = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvA), _NormalScale);
nA.z = lerp(1.0, nA.z, saturate(_NormalScale)); // 保持法线"平"的基底
float3 nB = UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvB), _NormalScale);
nB.z = lerp(1.0, nB.z, saturate(_NormalScale));

float3 surfaceNormal = BlendNormal(nA, nB); // URP 标准法线混合

设计技巧

_NormalScale 默认值仅 0.06 ,非常小。这是为了让水面波纹微妙而不夸张 ------ 折射偏移和反射扰动都受此控制,太大会导致折射重影。法线的 z 分量用 lerp(1, z, saturate(scale)) 保持基底平整,避免水面整体倾斜。

05 片元阶段 Fragment

屏幕折射与 RefractionMask 防穿帮

水面是半透明的,需要看到水下场景。本 Shader 使用 屏幕空间折射 :用法线驱动 UV 偏移,采样 _CameraOpaqueTexture(不透明场景颜色)。但折射偏移可能"穿帮"------偏移到水面以上的物体,所以需要 RefractionMask 来缩放偏移量。

RefractionMask.hlsl 核心逻辑

cs 复制代码
void RefractionMask(
    float2 screenUV,         // 水面屏幕 UV
    float2 refractionOffset,  // 法线驱动的偏移量
    float4 positionCS,        // 水面 clip space 位置 (NDC)
    float  fadeStart,         // 过渡起始
    float  fadeRange,         // 过渡范围
    out float  mask,            // 输出: 0~1 缩放因子
    out float2 finalOffset      // 输出: 缩放后的偏移
)
{
    // 1. 采样折射后位置的场景深度
    float rawDepth = SampleSceneDepth(screenUV + refractionOffset);
    float depthRefracted = LinearEyeDepth(rawDepth, _ZBufferParams);

    // 2. 水面自身的深度
    float waterDepth = LinearEyeDepth(positionCS.z, _ZBufferParams);

    // 3. 深度差 → smoothstep → mask
    float depthDiff = depthRefracted - waterDepth;
    mask = saturate(smoothstep(fadeStart, fadeStart + fadeRange, depthDiff));

    // 4. 偏移量 × mask → 穿帮时自动归零
    finalOffset = refractionOffset * mask;
}

⚠️ 关键:positionCS 必须是原始位置的 NDC

Shader 中保存了波浪位移前的原始 OS 位置 positionOSOriginal,在片元中重新做 TransformObjectToHClip + 透视除法得到 NDC。如果直接用波浪位移后的 positionCS,深度比较会出错。

Shader 中的调用

cs 复制代码
// 法线驱动折射偏移
float2 refractionOffset = (surfaceNormal * _RefractionIntensity).xy;

// 原始位置的 NDC(波浪位移前)
float4 positionCSOrig = TransformObjectToHClip(input.positionOSOriginal);
positionCSOrig /= positionCSOrig.w;

// RefractionMask:缩放偏移,防止穿帮
float refractionMask;
float2 finalOffset;
RefractionMask(screenUV, refractionOffset, positionCSOrig,
    _RefractionMask_FadeStart, _RefractionMask_FadeRange,
    refractionMask, finalOffset);

// 折射后的 UV → 采样场景颜色
float2 refractedUV = screenUV + finalOffset;
float3 sceneColor = SampleSceneColor(refractedUV);

06 片元阶段 Utility

折射后世界坐标重建

焦散需要知道水底的世界位置来生成 UV。关键设计:用折射偏移后的 UV 重建世界坐标,确保焦散和折射背景 100% 同步,不会重影。

cs 复制代码
// RefractedWorldPos.hlsl
float GetRefractedWorldPos(float2 refractedUV, out float3 worldPos)
{
    float rawDepth = SampleSceneDepth(refractedUV);
    worldPos = ComputeWorldSpacePosition(refractedUV, rawDepth, UNITY_MATRIX_I_VP);
    return 1.0; // 占位返回值(ASE 兼容)
}

// shader 中使用
float3 sceneWorldPos;
GetRefractedWorldPos(refractedUV, sceneWorldPos);
float refractedDepth = (positionWS - sceneWorldPos).y; // waterY - sceneY = 水深

💡 为什么用折射后的 UV 而不是原始 UV?

折射背景用偏移后 UV 采样,焦散如果用原始 UV 重建坐标,两者不同步 → 腿轮廓处焦散和折射错位 → 重影。用同一个 refractedUV 重建世界坐标,焦散 UV 和折射背景 100% 同步。

07 片元阶段 Fragment

焦散效果

焦散是水下光线的聚焦纹理,给水底增加动态光影感。本 Shader 使用 双纹理 Min 混合(取两张图的较小值),配合深度衰减和岸边淡出。

cs 复制代码
// 焦散 UV 基于折射后的水底世界坐标(与折射同步)
float2 causticUV = sceneWorldPos.xz * _CausticesTiling;
float2 causticScroll = _CausticesSpeed * _Time.y * 0.01;

// 双纹理反向滚动,取 Min(模拟光线聚焦的暗纹)
float3 causticA = SAMPLE_TEXTURE2D(_CausticesMap, sampler_CausticesMap, causticUV + causticScroll).rgb;
float3 causticB = SAMPLE_TEXTURE2D(_CausticesMap, sampler_CausticesMap, -causticUV + causticScroll).rgb;

// 岸边淡出:浅水处焦散渐弱
float causticShore = smoothstep(0.0, _CausticsShoreFade, max(refractedDepth, 0.0));

// 深度衰减:越深焦散越弱
float3 caustics = saturate(exp2(-refractedDepth / _ShallowRange1))
    * (min(causticA, causticB) * _CausticsIntensity * causticShore);

// 焦散叠加到场景色
float3 underWaterColor = sceneColor + caustics;

⚠️ 踩坑记录

焦散深度衰减 exp2(-refractedDepth / range)refractedDepth < 0(水面以上物体)时会返回 > 1 的值,saturate 后变成 1,导致焦散全亮出现在角色身上。必须加 causticShoresmoothstep(0, fade, max(depth,0)) 来消除。

08 片元阶段 Fragment

水面颜色与深度吸收

水面颜色由三部分混合:水下场景色(折射+焦散)水面颜色(深浅色+反射)吸收因子控制两者的混合比例。深度越大,水色越浓,场景越不可见。

cs 复制代码
// --- 水面颜色(深浅色 + 波峰色) ---
float aboveWater = saturate(-refractedDepth / 0.05); // 水面以上 → 1
float shallowFactor = saturate(exp2(-max(refractedDepth, 0.0) / _ShallowRange) * (1.0 - aboveWater));
float3 waterColor = lerp(_DeepColor.rgb, _ShallowColor.rgb, shallowFactor) + waveColor;

// --- 吸收因子 ---
float underwaterMask = GetRefractedUnderwaterMask(screenUV, finalOffset, positionCSOrig)
    * saturate(smoothstep(0.0, 0.02, refractedDepth));
float effectiveDepth = max(refractedDepth, 0.0) * underwaterMask;
float absorb = 1.0 - saturate(exp2(-(max(effectiveDepth, 0.0) * _AbsorbStrength)));
absorb = lerp(absorb, 1.0, aboveWater); // 水面以上 → 完全水色

// --- 最终混合 ---
float3 color = lerp(underWaterColor, waterAndReflection, absorb);
//            ↑ 场景色+焦散           ↑ 水色+反射

💡 关键设计

underwaterMask 使用 GetRefractedUnderwaterMask(窄过渡 smoothstep)+ smoothstep(0, 0.02, refractedDepth) 双重判断,确保水面以上物体不被水色污染。aboveWater 标记让水面以上区域直接走纯水色分支。

09 片元阶段 Fragment

平面反射与 Fresnel

反射来自外部脚本渲染的 _ReflectionTex(Planar Reflections)。水面法线的 xz 分量扰动反射 UV,再用 Fresnel 控制反射强度------掠射角时反射强,正视时反射弱。

cs 复制代码
// 反射 UV = 屏幕 UV + 法线扰动
float2 reflectionUV = PlanarReflectionUV(screenUV) + surfaceNormal.xz;

// 反射 Fresnel:掠射角强,正视弱
float reflectionFresnel = _ReflectionFresnelParam.x
    + _ReflectionFresnelParam.y * pow(max(1.0 - saturate(dot(normalWS, viewDirWS)), 0.0001), _ReflectionFresnelParam.z);

float3 reflectionColor = SAMPLE_TEXTURE2D(_ReflectionTex, sampler_ReflectionTex, reflectionUV).rgb
    * saturate(reflectionFresnel);

// 水色 Fresnel(另一组参数)
float waterFresnel = pow(max(1.0 - saturate(dot(normalWS, viewDirWS)), 0.0001), _FresnelPower);

// 水色 + 反射混合
float3 waterAndReflection = lerp(waterColor, reflectionColor, waterFresnel);

10 片元阶段 Fragment

岸边着色

岸边(浅水与陆地交界处)使用独立的 _ShoreColor 着色,并叠加边缘高光,让水陆交界更清晰自然。

cs 复制代码
// 岸边着色权重:浅水 → 1,深水 → 0
float waterShore = underwaterMask * saturate(exp2(-effectiveDepth / _ShoreRange));
float3 shoreColor = sceneColor * _ShoreColor.rgb;
color = lerp(color, shoreColor, waterShore);

// 岸边边缘高光
float shoreEdge = smoothstep(1.0 - _ShoreEdgeWidth, 1.0, waterShore) * _ShoreEdgeIntensity;
color += shoreEdge;

11 片元阶段 Fragment

泡沫效果

泡沫出现在浅水和岸边,使用 Simplex 噪声 + 正弦波 生成破碎纹理,通过 step 阈值控制泡沫分布。

cs 复制代码
// 深度因子:岸边 → 0,深水 → 1
float foamT = saturate(effectiveDepth / _FoamRange);
float foamShallow = 1.0 - smoothstep(_FoamBlend, 1.0, foamT);
float foamInv = 1.0 - foamT;

// Simplex 噪声 + 正弦波
float foamNoise = snoise(input.uv * _FoamNoiseSize);
foamNoise = foamNoise * 0.5 + 0.5;
float foamPattern = step(
    foamInv - _FoamWidth,
    sin(foamInv * _FoamFrequency + _FoamSpeed * _Time.y) + foamNoise - _FoamDissolve);

// 水面以上不生成泡沫
float waterDepthUnrefracted = positionWS.y - ReconstructWorldPosFromDepth(screenUV).y;
float foamAbove = saturate(-waterDepthUnrefracted / 0.05);

float4 foamColor = foamShallow * foamPattern * _FoamColor
    * underwaterMask * (1.0 - foamAbove);
color = lerp(color, foamColor.rgb, foamColor.a);

12 全流程

完整渲染管线流程

从顶点输入到最终像素颜色的完整数据流。

关键设计总结

🎯 核心设计理念

这个 Shader 的核心思路是 "屏幕空间 + 深度驱动":水面不是真正透明地渲染水下几何,而是通过屏幕 UV 偏移采样已渲染的不透明场景颜色,再用深度差驱动所有效果(焦散、吸收、泡沫、岸边)。

关键设计决策

设计点 方案 原因
折射 UV 法线驱动 + RefractionMask 缩放 法线给方向,Mask 防穿帮
焦散位置 折射后 UV 重建 WorldPos 焦散和折射 100% 同步,无重影
深度来源 waterY - sceneWorldPos.y 世界空间 Y 差,直观且准确
吸收混合 exp2(-depth × strength) 指数衰减,浅水透明深水不透明
水下遮罩 GetRefractedUnderwaterMask + smoothstep 双重判断防止水面以上物体被染色
法线强度 NormalScale = 0.06(极小) 微妙波纹,避免折射重影
波浪位移 3 层 Gerstner,保存原始位置 多方向叠加,原始位置用于深度比较
Alpha = 1.0(不透明) 混合在 RGB 中完成,避免边缘半透泛蓝
相关推荐
噗噗夹的TA之旅2 小时前
Shader 学习 21:自定义 Render Feature
学习·游戏·unity·c#·游戏引擎·图形渲染
zcmodeltech7 小时前
化工装置沙盘模型多设备协同控制系统设计:基于STM32与Modbus RTU的装置-流程-安全联动方案
大数据·stm32·嵌入式硬件·安全·unity·制造
暗影凋落1 天前
Unity 射线检测优化:使用 Job System 实现高性能射线批处理
unity·游戏引擎·lucene
淡海水1 天前
12-01-YooAsset工程化-Unity资源管理规范
unity·c#·游戏引擎·yooasset
BuHuaX1 天前
U3D抖微小游戏开发流程 (一)
unity·c#·游戏引擎·游戏策划
_ZHOURUI_H_2 天前
Unity MyFramework 用法说明(二十三):使用 PrefabPoolManager 复用预设实例
unity·游戏引擎·游戏开发·游戏ui·手游开发
冰凌糕2 天前
Unity3D Shader 法线与基础光照
unity
新手unity自用笔记2 天前
unity网络基础_1
网络·unity·游戏引擎
谢斯2 天前
[vscode] 使用unity打开vscode的取消.csproj的显示
ide·vscode·unity