当前位置: 首页 > news >正文

【Unity Shader URP】水面效果 实战教程

文章目录

    • 0. 效果预览
    • 1. 原理简述
    • 2. 功能点
    • 3. 完整 Shader(可直接用)
    • 4. 使用方法
      • 4.1 开启 URP 的 Opaque Texture 和 Depth Texture
      • 4.2 创建水面
      • 4.3 配置参数
      • 4.4 验证
    • 5. 参数说明
    • 6. 变体与扩展
      • 6.1 Flowmap 驱动的河流水面
      • 6.2 可交互水(物体激起的波纹)
      • 6.3 轻量版(移动端)
    • 7. 常见问题
    • 8. 性能建议

0. 效果预览

水面是游戏里少数"做一次,处处能用"的效果:顶点 Gerstner 波撑起起伏感,切线空间噪声法线撑起细碎波纹,_CameraOpaqueTexture折射撑起"水下看得见底",再用深度差在岸边刷一圈泡沫,就是一整套可直接落地的水面方案。


1. 原理简述

水面的本质:顶点管几何起伏,片元管视觉细节。Gerstner 波给几何形,噪声法线给微观涟漪,屏幕折射给通透感,深度差给岸边泡沫。

传统 Sin 波只上下位移(y = sin(x)),水面看起来像抖动的毯子,没有波峰堆起的感觉。Gerstner 波在 Sin 波基础上同时让顶点沿波传播方向位移,波峰会被"挤尖"、波谷会被"拉平",这才是真实水面的形状:

// D: 波方向 (xz 平面单位向量) w: 波频 Q: 陡峭度 phi: 相位 float k = dot(D, xz) * w + phi; x += Q * A * D.x * cos(k); z += Q * A * D.y * cos(k); y += A * sin(k);

一波太平,叠 3~4 个不同方向、不同频率的波就有了天然的随机感。

法线不能直接用(0,1,0):顶点被推尖了,法线也必须跟着变。两个做法,代价不同:

  1. 解析法线(本文用):把 Gerstner 公式对 x、z 求偏导,叉积算切线和副切线的法向量。代价小、结果准,但公式要推对
  2. 顶点差分:额外计算两个相邻顶点位置做差分。糙但省脑,适合简单 Sin 波

片元再叠一层法线贴图,两张同一张图按不同方向滚动,UnpackNormal 后混合,水面就有了"阳光下那种闪动的细碎涟漪"。

折射很直接:水面是透明物体,采样屏幕已渲染的不透明颜色(_CameraOpaqueTexture),用扰动法线的 xz 偏移 UV,水下的东西就会"扭"起来。URP 需要开启 Opaque Texture 才能拿到这张图。

岸边泡沫靠水面深度 - 场景深度:水面自身写入的线性深度和场景深度相减,差值越小(越靠近岸边),颜色越白。


2. 功能点

  • Gerstner 波叠加,支持 4 层不同方向/频率/陡峭度的波
  • 解析法线,波峰波谷高光正确
  • 两层滚动法线贴图,微观涟漪
  • 屏幕空间折射(采样_CameraOpaqueTexture
  • 基于深度差的水深渐变(浅蓝 → 深蓝)
  • 基于深度差的岸边泡沫线
  • Blinn-Phong 高光 + Fresnel 边缘亮
  • URP 单 Pass 透明物体,移动端可用(关掉波数可进一步加速)

3. 完整 Shader(可直接用)

Shader "Custom/WaterSurface_URP" { Properties { [Header(Color)] _ShallowColor ("Shallow Color", Color) = (0.3, 0.8, 0.85, 1) // 浅水颜色(岸边) _DeepColor ("Deep Color", Color) = (0.05, 0.2, 0.4, 1) // 深水颜色 _DepthRange ("Depth Range", Range(0.1, 20)) = 4.0 // 从浅到深的距离(米) _Transparency ("Transparency", Range(0, 1)) = 0.85 // 最终透明度上限 [Header(Gerstner Waves)] _WaveA ("Wave A (dir xy, steepness, wavelength)", Vector) = (1, 0, 0.5, 6) // 方向 xz / 陡峭度 / 波长 _WaveB ("Wave B (dir xy, steepness, wavelength)", Vector) = (0, 1, 0.3, 4) _WaveC ("Wave C (dir xy, steepness, wavelength)", Vector) = (1, 1, 0.25, 3) _WaveD ("Wave D (dir xy, steepness, wavelength)", Vector) = (1, -0.6, 0.2, 2) _WaveSpeed ("Wave Speed", Range(0, 3)) = 1.0 // 整体时间缩放 [Header(Normal Ripples)] _NormalMap ("Normal Map", 2D) = "bump" {} // 法线贴图(可平铺噪声法线) _NormalScale1 ("Normal Tiling 1", Vector) = (0.3, 0.3, 0, 0) // 第一层缩放 _NormalScale2 ("Normal Tiling 2", Vector) = (0.7, 0.7, 0, 0) // 第二层缩放 _NormalSpeed1 ("Normal Flow 1 (xy)", Vector) = (0.05, 0.02, 0, 0) // 第一层流动速度 _NormalSpeed2 ("Normal Flow 2 (xy)", Vector) = (-0.03, 0.04, 0, 0) // 第二层流动速度 _NormalStrength ("Normal Strength", Range(0, 2)) = 1.0 // 法线强度 [Header(Refraction)] _RefractStrength ("Refraction Strength", Range(0, 0.1)) = 0.02 // 屏幕 UV 扭曲幅度 [Header(Lighting)] _SpecColor2 ("Specular Color", Color) = (1, 1, 1, 1) // 高光颜色 _Smoothness ("Smoothness", Range(0, 1)) = 0.85 // 高光光滑度 _FresnelPow ("Fresnel Power", Range(0.5, 8)) = 4.0 // Fresnel 指数 _FresnelTint ("Fresnel Tint", Color) = (0.8, 0.95, 1, 1) // Fresnel 叠色(天光) [Header(Foam)] _FoamColor ("Foam Color", Color) = (1, 1, 1, 1) // 泡沫颜色 _FoamRange ("Foam Range", Range(0.01, 3)) = 0.5 // 泡沫出现的深度范围 _FoamNoiseStrength ("Foam Noise Strength", Range(0, 1)) = 0.3 // 法线图扰动泡沫边缘 } SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } LOD 200 Pass { Name "WaterForward" Tags { "LightMode"="UniversalForward" } Blend Off // 折射已在片元里手动合成,关闭硬件混合避免双重混合 ZWrite On // 水面写深度,方便后面更靠近的透明物体正确排序 Cull Off // 双面可见,避免摄像机钻进水里看不到背面 HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing #pragma multi_compile_fog #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float4 positionHCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; float2 uv : TEXCOORD2; float4 screenPos : TEXCOORD3; float fogCoord : TEXCOORD4; UNITY_VERTEX_INPUT_INSTANCE_ID }; TEXTURE2D(_NormalMap); SAMPLER(sampler_NormalMap); CBUFFER_START(UnityPerMaterial) float4 _ShallowColor; float4 _DeepColor; float _DepthRange; float _Transparency; float4 _WaveA; float4 _WaveB; float4 _WaveC; float4 _WaveD; float _WaveSpeed; float4 _NormalMap_ST; float4 _NormalScale1; float4 _NormalScale2; float4 _NormalSpeed1; float4 _NormalSpeed2; float _NormalStrength; float _RefractStrength; float4 _SpecColor2; float _Smoothness; float _FresnelPow; float4 _FresnelTint; float4 _FoamColor; float _FoamRange; float _FoamNoiseStrength; CBUFFER_END // ===== Gerstner 波:返回位移,通过 ref 累加切线/副切线 ===== // wave.xy = 方向 (xz 平面) wave.z = 陡峭度 Q wave.w = 波长 // 切线 T = ∂P/∂x,副切线 B = ∂P/∂z,法线 = normalize(cross(B, T)) float3 GerstnerWave(float4 wave, float3 p, inout float3 tangent, inout float3 binormal) { float steepness = wave.z; float wavelength = wave.w; float k = 2.0 * PI / wavelength; // 角波数 float c = sqrt(9.8 / k); // 深水重力波相速度 c = √(g/k) float2 d = normalize(wave.xy); // 波方向归一化 float f = k * (dot(d, p.xz) - c * _Time.y * _WaveSpeed); // 相位 float a = steepness / k; // 振幅 A = Q/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))); // 位移:xz 沿传播方向挤压,y 竖直起伏 return float3(d.x * (a * cos(f)), a * sin(f), d.y * (a * cos(f))); } Varyings vert(Attributes IN) { Varyings OUT = (Varyings)0; UNITY_SETUP_INSTANCE_ID(IN); UNITY_TRANSFER_INSTANCE_ID(IN, OUT); // 物体空间 → 世界空间再叠波,保证波形跟世界坐标对齐(多块水面不会错位) float3 posWS = TransformObjectToWorld(IN.positionOS.xyz); float3 tangent = float3(1, 0, 0); // 初始切线 float3 binormal = float3(0, 0, 1); // 初始副切线 posWS += GerstnerWave(_WaveA, posWS, tangent, binormal); posWS += GerstnerWave(_WaveB, posWS, tangent, binormal); posWS += GerstnerWave(_WaveC, posWS, tangent, binormal); posWS += GerstnerWave(_WaveD, posWS, tangent, binormal); // 解析法线:副切线 × 切线(注意顺序决定正负) float3 normalWS = normalize(cross(binormal, tangent)); OUT.positionWS = posWS; OUT.normalWS = normalWS; OUT.positionHCS = TransformWorldToHClip(posWS); OUT.uv = TRANSFORM_TEX(IN.uv, _NormalMap); OUT.screenPos = ComputeScreenPos(OUT.positionHCS); OUT.fogCoord = ComputeFogFactor(OUT.positionHCS.z); return OUT; } half4 frag(Varyings IN) : SV_Target { UNITY_SETUP_INSTANCE_ID(IN); // ===== 1. 两层法线贴图混合,做微观涟漪 ===== float2 uv1 = IN.positionWS.xz * _NormalScale1.xy + _Time.y * _NormalSpeed1.xy; float2 uv2 = IN.positionWS.xz * _NormalScale2.xy + _Time.y * _NormalSpeed2.xy; float3 n1 = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv1)); float3 n2 = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv2)); float3 nTS = normalize(float3(n1.xy + n2.xy, n1.z * n2.z)); // "whiteout"(白化)混合:xy 相加,z 相乘 nTS.xy *= _NormalStrength; nTS = normalize(nTS); // 水面切线空间的简单构造:顶点法线已由 Gerstner 解析得到 // 这里把切线空间法线的 x/z 扰动累加到世界法线上,避免重建完整 TBN 的开销 float3 normalWS = normalize(IN.normalWS + float3(nTS.x, 0, nTS.y) * _NormalStrength); // ===== 2. 深度差:水面自身屏幕深度 vs 场景深度 ===== float2 screenUV = IN.screenPos.xy / IN.screenPos.w; float sceneRawDepth = SampleSceneDepth(screenUV); float sceneEyeDepth = LinearEyeDepth(sceneRawDepth, _ZBufferParams); float waterEyeDepth = LinearEyeDepth(IN.positionHCS.z, _ZBufferParams); float waterDepth = max(0, sceneEyeDepth - waterEyeDepth); // 水深(米) // ===== 3. 颜色渐变:浅 → 深 ===== float depthT = saturate(waterDepth / _DepthRange); half3 waterCol = lerp(_ShallowColor.rgb, _DeepColor.rgb, depthT); // ===== 4. 屏幕空间折射:用法线扰动采样不透明贴图 ===== float2 refractOffset = normalWS.xz * _RefractStrength; float2 refractUV = saturate(screenUV + refractOffset); half3 sceneCol = SampleSceneColor(refractUV); // ===== 5. 透明度:随水深变不透明 ===== float alpha = lerp(0.2, _Transparency, depthT); half3 col = lerp(sceneCol, waterCol, alpha); // ===== 6. Blinn-Phong 高光 + Fresnel ===== Light mainLight = GetMainLight(); float3 L = normalize(mainLight.direction); float3 V = GetWorldSpaceNormalizeViewDir(IN.positionWS); float3 H = normalize(L + V); float NdotH = saturate(dot(normalWS, H)); float NdotV = saturate(dot(normalWS, V)); float specPow = lerp(16.0, 512.0, _Smoothness); half3 spec = _SpecColor2.rgb * mainLight.color * pow(NdotH, specPow); float fresnel = pow(1.0 - NdotV, _FresnelPow); col += _FresnelTint.rgb * fresnel * 0.5; col += spec; // ===== 7. 岸边泡沫:水深越小越白,法线扰动边缘 ===== float foamCutoff = _FoamRange + nTS.x * _FoamNoiseStrength; float foamMask = 1.0 - saturate(waterDepth / max(foamCutoff, 0.001)); foamMask = smoothstep(0.0, 1.0, foamMask); col = lerp(col, _FoamColor.rgb, foamMask); // ===== 8. 雾 ===== col = MixFog(col, IN.fogCoord); // Blend Off 时 alpha 不参与硬件混合,写 1 即可 return half4(col, 1); } ENDHLSL } } FallBack Off }

4. 使用方法

4.1 开启 URP 的 Opaque Texture 和 Depth Texture

  1. 项目里找到当前使用的UniversalRenderPipelineAsset(Project Settings → Graphics → Scriptable Render Pipeline Settings)
  2. 勾选:
    • Opaque Texture✅(水面折射采样_CameraOpaqueTexture
    • Depth Texture✅(深度渐变和泡沫需要_CameraDepthTexture

没开这两项,水面会变成一块纯色。

4.2 创建水面

  1. 新建Plane(Hierarchy → 3D Object → Plane),把它拉大或换成 10×10 细分的 Mesh。顶点密度决定波形精细度,Plane 默认 10×10 够用,更大的水体建议ProBuilder生成高密度面
  2. 新建.shader文件,粘贴上面代码
  3. 新建材质,Shader 选Custom/WaterSurface_URP
  4. 把材质拖到 Plane 上

4.3 配置参数

  • Normal Map:拖入任意无缝水面法线贴图(Unity Standard Assets 自带WaterNormals,网上搜 “water normal seamless” 也能找到)
  • Wave A/B/C/D:保持默认或微调方向(xy 是 xz 平面的波向)和波长
  • Depth Range:根据场景尺度调。小池塘给 2-4,海面给 10-30
  • Shallow/Deep Color:浅水偏青、深水偏蓝是经典配色;热带风格把浅水调得更亮

4.4 验证

  • 相机拉近看:顶点应该起伏
  • 岸边应该出现一圈白色泡沫
  • 水下放个 Cube,应该能看到 Cube 被扭曲

5. 参数说明

参数类型默认值说明
_ShallowColorColor(0.3, 0.8, 0.85, 1)浅水颜色
_DeepColorColor(0.05, 0.2, 0.4, 1)深水颜色
_DepthRangeRange(0.1, 20)4.0颜色从浅到深过渡的距离(米)
_TransparencyRange(0, 1)0.85深处的最大不透明度
_WaveA…DVector见代码xy=方向, z=陡峭度, w=波长
_WaveSpeedRange(0, 3)1.0整体时间缩放
_NormalMap2Dbump法线贴图
_NormalScale1/2Vector(0.3,0.3)/(0.7,0.7)两层法线的平铺倍率
_NormalSpeed1/2Vector见代码两层法线的流动速度
_NormalStrengthRange(0, 2)1.0法线总强度
_RefractStrengthRange(0, 0.1)0.02屏幕折射扰动幅度
_SpecColor2Color(1,1,1,1)高光颜色
_SmoothnessRange(0, 1)0.85高光光滑度
_FresnelPowRange(0.5, 8)4.0Fresnel 指数(越大越贴边缘)
_FresnelTintColor(0.8, 0.95, 1, 1)Fresnel 叠加的天光色
_FoamColorColor(1,1,1,1)泡沫颜色
_FoamRangeRange(0.01, 3)0.5泡沫的深度范围(米)
_FoamNoiseStrengthRange(0, 1)0.3法线扰动泡沫边缘的幅度

6. 变体与扩展

6.1 Flowmap 驱动的河流水面

让水沿指定方向"流"起来(适合瀑布、溪流)。核心是把 Flowmap(RG 通道表示流向)替换原本的固定_NormalSpeed

// flow: Flowmap 采样,RG∈[-1,1] float2 flow = SAMPLE_TEXTURE2D(_FlowMap, sampler_FlowMap, IN.positionWS.xz * 0.1).rg * 2 - 1; float t = frac(_Time.y * 0.5); float wA = 1 - abs(1 - 2 * t); // 两个相位交替混合,避免"跳帧" float2 uvA = IN.uv + flow * t; float2 uvB = IN.uv + flow * (t - 0.5) + 0.5; float3 nA = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvA)); float3 nB = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uvB)); float3 nTS = lerp(nA, nB, wA);

效果:水会沿 Flowmap 绘制的方向流动,适合用 Polybrush 刷方向。

6.2 可交互水(物体激起的波纹)

本 Shader 基础上加一张 RenderTexture,用来记录物体接触水面的位置。每帧:

  1. 用 C# 脚本把接触点绘制到 RenderTexture(加法混合)
  2. Shader 把 RT 采样作为额外的法线扰动

这是"鸭子划过的波纹"等需要局部动态响应时的做法。

6.3 轻量版(移动端)

关掉 3 个 Gerstner 波、去掉 Fresnel、合并两层法线为一层,性能可再降 40%。原则:顶点波数 → 法线层数 → 折射 → 泡沫,按顺序砍。


7. 常见问题

Q: 水面整块变成纯色,看不到水下的东西?
A: URP Asset 的 “Opaque Texture” 没勾上。勾上后SampleSceneColor才有内容。

Q: 水面是平的,不会起伏?
A: 检查三点:(1) Plane 的顶点数够不够(默认 Plane 有 121 顶点,但 Cube 上面只有 4 个顶点,波会消失);(2)_WaveSpeed是否为 0;(3) 材质上 Wave A/B/C/D 的陡峭度(z 分量)不能全是 0。

Q: 岸边泡沫没出现?
A: URP Asset 的 “Depth Texture” 没勾。或者水面下方没有不透明物体写深度,导致sceneEyeDepth拿到的是远裁剪面。

Q: 高光点太多太碎?
A: 把_NormalStrength降到 0.3~0.5,或_Smoothness调小。高光锐度由两者共同决定。

Q: 从水下往上看是一片黑?
A: 本 Shader 是单 Pass 双面(Cull Off),但背面没做折射。真实水下渲染需要单独背面 Pass + 水下雾,超出本文范围。实战里常用的妥协:背面贴一个更深的颜色即可。


8. 性能建议

  • 顶点波数:Gerstner 波每增加一层,顶点计算量线性增加。移动端建议最多 2 层;PC 4 层;3A 级可以 6~8 层
  • 水面 Mesh 密度:波形是顶点级的,面数过低会锯齿化。用 LOD 分级:远处低密度 Plane,近处高密度 Mesh
  • 折射开关:如果场景不需要看水下(浑浊河道、夜间水面),完全移除SampleSceneColor,性能回升明显
  • 法线贴图尺寸:256/512 够用,水面本来就在动,分辨率过高看不出来
  • Opaque Texture 的代价:URP 为它额外 Blit 一次屏幕,有水面的场景建议只在有水的关卡开启,无水关卡关掉节省带宽
  • Cull Off 的代价:双面光栅化意味着 Overdraw 翻倍。如果摄像机永远在水面上方,改回Cull Back能省一半片元
http://www.cnnetsun.cn/news/2620148.html

相关文章:

  • 构建可靠RAG系统:数据摄取流水线核心环节与实战优化
  • 5分钟快速上手:applera1n激活锁绕过工具终极指南
  • 构建统一LLM API调用层:适配OpenAI、Claude、Gemini与开源模型
  • 别再只用GeoHash了!用Uber H3六边形网格搞定空间数据分析(Python实战)
  • 别再死记硬背了!用Python+MATLAB/Simulink,手把手带你仿真二阶系统的‘稳、快、准’
  • rtklib 2.4.3源码在VS2019中的高效调试技巧:从单步跟踪到实时变量监控
  • Unity ShaderGraph实战:用一张贴图和几个节点,5分钟搞定动态火焰特效
  • 哥斯拉流量分析实战:用Wireshark解密NewStarCTF Week4的WebShell通信
  • TP4056锂电池充电电路设计:解决嵌入式设备充电重启与续航难题
  • 基于树莓派Pico W与CircuitPython的辅助运动玩具设计与实现
  • 2026年口碑封口机制造厂专业推荐
  • Agent设计模式
  • 做搜索和内容生态来看!AI 原生搜索时代的架构跃迁与 GEO
  • Deepseek-V4-Flash 快速部署与调用实战指南
  • 受载煤体表面裂纹扩展规律与声电效应实验及应用方案【附数据】
  • 防雷接地计算规则
  • Go语言泛型方法提案:打破限制,增强代码编写能力
  • Ai2Psd:如何高效实现AI到PSD的专业矢量图层转换?
  • BallonsTranslator:深度学习赋能漫画翻译,3分钟完成专业级本地化解决方案
  • 猫抓浏览器扩展:终极网页资源嗅探工具完全指南
  • 大模型转行必看:小白程序员如何入行大模型赛道?收藏这份学习指南!
  • 如何为你的项目快速安装并配置Taotoken的Python调用包
  • 文献 建立了 VoronaGasyCodes 鸟类公共数据库
  • 《流畅的Python》读书笔记14(补充01): 从协议到抽象基类 - 策略模式实现动态折扣计算
  • 通达信缠论可视化插件:3分钟掌握复杂缠论分析技巧
  • 告别SSH断连烦恼:保姆级配置ClientAliveInterval与ClientAliveCountMax(附一键脚本)
  • 2026年怎么样弄自己店的小程序?
  • 长期使用Taotoken服务在计费透明性与客服响应上的感受
  • 安达|aps软件:解锁半导体智能制造的核心“引擎密码”
  • 用SigmaStudio Plus如何来开发ADAU1466(4)实现模拟的4进8出