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

从零实现可交互水面Shader:原理、实战与性能优化

1. 先搞清楚“水面Shader”到底要解决什么问题

如果你正在Unity、UE或者Three.js里做项目,需要一片能看的水面,那这篇文章就是为你准备的。网上很多水面Shader教程要么只讲一个波纹公式,要么直接丢给你一个看不懂的复杂工程。结果就是你复制粘贴后,要么效果假得像塑料布,要么性能卡得没法用,更别提让水面跟角色、子弹或者鼠标互动了。

所以,这篇文章不打算罗列所有数学公式,而是直接拆解一个可交互、有质感、性能可控的水面Shader从零到一的实现思路。核心就三件事:基础视觉(波纹、颜色)、交互逻辑(扰动、涟漪)、性能边界(复杂度、平台兼容)。无论你是想快速实现一个湖面,还是为游戏设计动态海域,都可以从这里找到可落地的步骤和参数调节逻辑。

我会假设你至少对ShaderLab或HLSL/GLSL有基础了解,知道什么是顶点/片元着色器。如果你完全没写过Shader,建议先了解下基础语法再回来看,会顺畅很多。

2. 环境与核心思路:别急着写代码,先想清楚管线

在动手写任何一行Shader代码之前,必须先明确你的渲染管线目标平台。这是很多水面效果“移植就崩”的根源。

2.1 渲染管线决定你的工具链

  • Unity内置管线/Built-in RP:最通用,资料最多。你可以用传统的Surface Shader(方便但黑盒)或者自己写Unlit Shader(更可控)。本文的技法主要基于可编程着色器思路,在内置管线里同样适用。
  • Unity URP(Universal Render Pipeline):现在是Unity的主流推荐。它要求你使用Shader Graph(可视化)或者HLSL代码(URP Shader Library模板)。如果你的项目是URP,那么传统内置管线的Surface Shader写法大概率不兼容。本文的原理相通,但具体实现(如获取深度图、摄像机参数的方式)需要按URP的API来。
  • Unity HDRP(High Definition RP):追求电影级画质,自带更复杂的水体系统。如果你用HDRP,通常不建议从头造轮子,而是基于其Lit Shader模板或Visual Effect Graph进行扩展。
  • Three.js / WebGL:在GLSL中实现,核心原理一致,但需要注意WebGL 1.0/2.0的特性支持(如浮点纹理、Mipmap采样),以及如何通过JavaScript传递交互数据(如鼠标点击位置)。

我的建议是:如果是学习或快速原型,用Unity内置管线或URP+Shader Graph最容易出效果。如果是严肃的移动端项目,URP是更稳妥的选择,因为其对移动平台优化更好。

2.2 交互式水面的核心组件拆解

一个看起来“活”的水面,通常由以下几层效果叠加而成,我们需要逐一攻破:

  1. 几何变形(顶点动画):让平面“起伏”起来,产生波浪。这是性能消耗的大头。
  2. 法线与光照:根据变形后的表面计算法线,与灯光交互,产生明暗、高光(镜面反射),这是质感的关键。
  3. 颜色与透明度:模拟水的颜色、深度衰减(岸边浅、深处蓝)、透明度。
  4. 反射与折射:高级效果,可能需要渲染反射贴图(RenderTexture)或使用屏幕空间反射(SSR)。
  5. 交互扰动:响应外部力(如角色入水、子弹击中、鼠标点击),产生局部涟漪。

我们按从易到难的顺序,先实现一个有基础波浪和光照的静态水面,再加入交互式涟漪

3. 实战第一步:构建基础波浪水面

我们从最简单的开始:一个会动的正弦波平面。

3.1 创建基础Shader与材质

在Unity中,创建一个Unlit Shader(或URP Unlit Shader),命名为“WaterBasic”。为其创建一个材质“Mat_WaterBasic”。

3.2 顶点着色器:实现正弦波变形

波浪的本质是让网格顶点的y轴(高度)随时间变化。我们使用最经典的正弦波叠加公式。

// 在顶点着色器函数中(例如 vert) v2f vert (appdata v) { v2f o; // 1. 计算世界空间下的波浪偏移 float waveFrequency = _WaveFrequency; // 波浪频率,控制波峰间距 float waveSpeed = _WaveSpeed; // 波浪速度 float waveHeight = _WaveHeight; // 波浪高度 // 使用顶点在世界XZ平面的位置和时间来采样波浪 float wave = sin((v.vertex.x * waveFrequency) + (_Time.y * waveSpeed)); wave += sin((v.vertex.z * waveFrequency * 1.3) + (_Time.y * waveSpeed * 1.7)) * 0.5; // 叠加第二层不同频率的波,让波形更自然 // 2. 应用高度偏移到顶点Y轴 v.vertex.y += wave * waveHeight; // 3. 计算变形后的法线(近似计算) // 这是一个简化版,更准确的法线需要基于偏导数,后面会讲 float derivative = cos((v.vertex.x * waveFrequency) + (_Time.y * waveSpeed)) * waveFrequency; float3 tangent = normalize(float3(1, derivative, 0)); float3 binormal = float3(0, 0, 1); // 简化假设 v.normal = cross(binormal, tangent); o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; }

在Properties块中声明对应的参数:

_WaveFrequency("Wave Freq", Range(0.1, 2)) = 0.5 _WaveSpeed("Wave Speed", Range(0, 5)) = 1.0 _WaveHeight("Wave Height", Range(0, 1)) = 0.1

关键参数解读:

  • _WaveFrequency:值越大,波峰越密集,波浪看起来更“碎”。通常从0.5开始调节。
  • _WaveSpeed:控制波浪动画的快慢。设为0则水面静止。
  • _WaveHeight:波的振幅,直接决定起伏的剧烈程度。过大会导致模型穿帮。

此时运行,你应该能看到一个平面像布一样上下起伏了。但这还远远不够,它没有水的质感。

3.3 片元着色器:加入颜色与基础光照

水的颜色不是单一色,它通常由三部分组成:

  1. 底色:水体的基本颜色。
  2. 深度色:越深的地方颜色越深(通常偏蓝/绿)。
  3. 菲涅尔效应:视线与水面夹角越小(看远处水面),反射越强;夹角越大(看脚下水面),折射(透出底部)越强。

我们先实现一个简化的版本:

// 在片元着色器函数中(例如 frag) fixed4 frag (v2f i) : SV_Target { // 1. 基础颜色 fixed4 baseColor = _BaseColor; // 2. 简易深度模拟:用世界空间Y轴或视角深度做一个渐变 // 这里假设水面在Y=0,水下物体有深度 float depth = max(0, -i.worldPos.y); // 一个简单的模拟 fixed4 depthColor = lerp(_ShallowColor, _DeepColor, saturate(depth / _DepthMax)); // 3. 简易菲涅尔 float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz); float fresnel = saturate(dot(viewDir, i.worldNormal)); fresnel = pow(1.0 - fresnel, _FresnelPower); // 菲涅尔系数 // 菲涅尔值大(看远处)时,混合更多“反射色”(这里用高光色模拟) fixed4 fresnelColor = lerp(baseColor, _SpecularColor, fresnel); // 4. 混合颜色 fixed4 finalColor = depthColor * fresnelColor; // 5. 加入简单的高光(Blinn-Phong简化版) float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float spec = pow(max(0, dot(normalize(viewDir + lightDir), i.worldNormal)), _Gloss); finalColor.rgb += _SpecularColor.rgb * spec * _SpecularIntensity; return finalColor; }

同样,在Properties中补充:

_BaseColor("Base Color", Color) = (0.1, 0.3, 0.5, 1) _ShallowColor("Shallow Color", Color) = (0.2, 0.6, 0.8, 0.8) _DeepColor("Deep Color", Color) = (0.0, 0.1, 0.3, 1) _DepthMax("Depth Max", Range(0.1, 10)) = 5 _FresnelPower("Fresnel Power", Range(1, 10)) = 5 _SpecularColor("Specular Color", Color) = (1,1,1,1) _Gloss("Gloss", Range(8, 256)) = 64 _SpecularIntensity("Specular Intensity", Range(0, 2)) = 0.5

现在你的水面应该有基本的颜色渐变和高光点了。但法线还是我们顶点着色器里简单计算的,很粗糙。

3.4 使用法线贴图:质感的飞跃

真实水面的细节(细碎波纹)靠顶点动画计算代价太高,标准做法是使用法线贴图(Normal Map)。我们采样一张水的法线贴图,并让其滚动,来模拟高频细节。

  1. 获取法线贴图:在Asset Store搜索“Water Normal Map”或使用类似下图样式的纹理。确保纹理导入设置中,Texture Type设为Normal map

  2. 在Shader中采样并滚动

// 在片元着色器中,计算法线贴图贡献的法线 float2 normalUV1 = i.uv + _Time.y * _NormalSpeed1; float2 normalUV2 = i.uv * _NormalScale2 + _Time.y * _NormalSpeed2 * float2(1.0, -0.8); // 第二层反向滚动,增加细节 fixed3 normalTex1 = UnpackNormal(tex2D(_NormalMap, normalUV1)); fixed3 normalTex2 = UnpackNormal(tex2D(_NormalMap, normalUV2)); fixed3 detailNormal = normalize(float3(normalTex1.xy + normalTex2.xy, normalTex1.z * normalTex2.z)); // 混合两层法线 // 将细节法线从切线空间转换到世界空间(需要传入切线TANGENT数据) float3 worldNormal = normalize(i.worldNormal); float3 worldTangent = normalize(i.worldTangent); float3 worldBitangent = normalize(cross(worldNormal, worldTangent)) * i.worldTangent.w; // 手性 float3x3 TBN = float3x3(worldTangent, worldBitangent, worldNormal); worldNormal = normalize(mul(detailNormal, TBN)); // 得到最终的世界空间法线

用这个更精细的worldNormal去替换之前计算光照和菲涅尔时用的i.worldNormal,水面的光影瞬间会变得丰富、真实。

至此,一个视觉效果不错的静态水面就完成了。你可以调节波浪参数、颜色和法线贴图速度来匹配你的场景风格(平静池塘、湍急河流或海洋)。

4. 核心难点:实现交互式涟漪

静态水面再好,没有交互也缺乏生气。交互的核心是:让Shader知道“哪里”被“何时”“多大力”地触碰了,并在该点生成一个随时间扩散的波纹。

4.1 交互数据的传递:RenderTexture方案

CPU(游戏逻辑)和GPU(Shader)通信最灵活的方式之一,是使用一张RenderTexture作为“交互信息贴图”。这张贴图的每个像素可以记录水面某个对应位置的“扰动高度”或“扰动时间”。

实现步骤:

  1. 创建交互贴图:在脚本中创建一张RenderTexture(例如128x128),初始化为纯黑(0)。将它传递给Shader作为一个Texture2D属性(如_InteractionMap)。
  2. 在脚本中绘制交互:当发生交互时(如鼠标点击、角色入水),在脚本中通过Graphics.BlitCommandBuffer,以一个“涟漪笔刷”纹理(一个从中心白向边缘黑衰减的圆形)去绘制(叠加)到这张_InteractionMap上。绘制的位置(UV)由交互的世界XZ坐标转换到水面的局部UV坐标得到。
  3. 在Shader中解码交互:在片元着色器中,采样_InteractionMap。采样到的值(比如r通道)代表该点当前的“扰动强度”。用这个强度值去进一步偏移顶点高度或扰动法线。
  4. 衰减与清除:每一帧,在将_InteractionMap传递给Shader前,先用一个衰减因子(如0.98)去乘整个贴图,让旧的涟漪逐渐消失。也可以定期完全清除。

这是一个简化的C#脚本示例(用于Unity):

public class WaterInteraction : MonoBehaviour { public RenderTexture interactionRT; // 拖拽赋值或代码创建 public Material waterMaterial; public Texture2D rippleBrush; // 涟漪笔刷纹理 public float rippleStrength = 1.0f; public float rippleDecay = 0.98f; private Material drawMaterial; // 用于绘制的临时材质 void Start() { // 初始化RenderTexture if (interactionRT == null) { interactionRT = new RenderTexture(128, 128, 0, RenderTextureFormat.RFloat); // 单精度浮点格式精度更高 interactionRT.wrapMode = TextureWrapMode.Clamp; interactionRT.Create(); } waterMaterial.SetTexture("_InteractionMap", interactionRT); drawMaterial = new Material(Shader.Find("Hidden/Internal-GUITextureClip")); // 使用一个简单的绘制Shader } void Update() { // 每帧衰减交互贴图 Graphics.Blit(interactionRT, interactionRT, drawMaterial, 0); // 通过一个*0.98的材质进行衰减Blit // 更优做法:使用Compute Shader进行高效的逐像素衰减。 // 检测交互(例如鼠标点击) if (Input.GetMouseButtonDown(0)) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit) && hit.collider.gameObject == this.gameObject) { AddRippleAtPosition(hit.textureCoord); // hit.textureCoord是水面模型UV } } } void AddRippleAtPosition(Vector2 uv) { // 设置绘制材质参数 drawMaterial.SetTexture("_MainTex", rippleBrush); drawMaterial.SetFloat("_Strength", rippleStrength); drawMaterial.SetVector("_Center", new Vector4(uv.x, uv.y, 0, 0)); // 临时切换RenderTexture,绘制笔刷 RenderTexture.active = interactionRT; GL.PushMatrix(); GL.LoadPixelMatrix(0, 1, 0, 1); // 使用UV坐标空间 Graphics.DrawTexture(new Rect(uv.x - 0.05f, uv.y - 0.05f, 0.1f, 0.1f), rippleBrush, drawMaterial); GL.PopMatrix(); RenderTexture.active = null; } void OnDestroy() { if (interactionRT != null) interactionRT.Release(); } }

4.2 在Shader中应用交互数据

在顶点或片元着色器中,采样_InteractionMap,并用其值影响顶点高度或法线。

// 在顶点着色器中 float interaction = tex2Dlod(_InteractionMap, float4(i.uv, 0, 0)).r; // 使用lod采样,避免在顶点着色器中使用tex2D // 将交互强度转换为高度偏移 float rippleHeight = interaction * _RippleHeightScale; v.vertex.y += rippleHeight; // 或者,更常见的是在片元着色器中影响法线,效果更柔和且性能更好 // 在片元着色器中 float interaction = tex2D(_InteractionMap, i.uv).r; // 计算交互带来的法线扰动 float2 gradient = float2(ddx(interaction), ddy(interaction)); // 求梯度,模拟涟漪的斜率 detailNormal.xy += gradient * _RippleNormalStrength; detailNormal = normalize(detailNormal);

关键点:交互逻辑的复杂度与性能直接相关。高分辨率的_InteractionMap、每帧多次绘制、复杂的衰减计算都会带来开销。对于移动平台,务必简化。

5. 性能优化与平台兼容性排查

一个好看的水面Shader如果让帧率暴跌,就失去了意义。以下是关键的优化和排查点。

5.1 性能开销大头分析

  1. 顶点动画:每个顶点每帧都要做正弦波计算。对策:控制水面网格的面数。对于大水面,使用LOD(Level of Detail),远处用低模+简单Shader,近处用高模+完整效果。
  2. 法线贴图采样:特别是多张法线贴图混合和滚动。对策:将多层法线贴图合并到一张RGBA贴图中(如R通道存第一层,G通道存第二层),减少采样次数。在移动端,考虑只使用一层。
  3. RenderTexture交互:每帧对RT进行读写和衰减。对策:降低RT分辨率(64x64有时也够用)。使用ComputeShader进行高效的并行衰减和叠加计算,比Graphics.Blit更快。
  4. 复杂光照计算:每像素多次的dotpownormalize操作。对策:在URP中,利用其内置的光照函数。在Built-in管线中,考虑使用简化的光照模型(如半兰伯特)。
  5. 反射/折射:实时渲染反射贴图(将场景渲染到纹理)是性能杀手。对策:使用平面反射(Planar Reflection)技术,但只反射近处重要物体;或者使用屏幕空间反射(SSR),但注意硬件支持度和瑕疵。对于风格化或移动项目,经常用一张**天空盒立方体贴图(Cubemap)**模拟反射,性价比最高。

5.2 常见平台问题排查清单

  • OpenGL ES 3.0 / WebGL 1.0 兼容性
    • 问题ddx/ddy(偏导数)函数可能不被支持或行为不一致,这会影响法线贴图、边缘光、交互梯度计算。
    • 排查:检查Shader编译日志。如果报错,考虑使用预计算的法线贴图,或禁用依赖ddx/ddy的效果。
  • RenderTexture格式
    • 问题:移动设备上RenderTextureFormat.RFloat可能不支持。
    • 排查:使用SystemInfo.SupportsRenderTextureFormat进行检查。回退方案是使用ARGBHalfARGB32格式,但精度会降低。
  • Adreno GPU(部分高通芯片)特定问题
    • 问题:在顶点着色器中采样纹理(tex2Dlod)可能有性能问题或精度问题。
    • 排查:尽量将交互数据通过顶点数据(如颜色或第二套UV)传递,或在片元着色器中采样后通过插值器传到顶点阶段(这需要额外Pass,较复杂)。最简单的方案是降低交互精度要求。
  • Overdraw(过度绘制)
    • 问题:半透明水面会覆盖大量像素,且可能多层叠加。
    • 排查:使用Unity的Frame Debugger或Overdraw视图查看。确保水面材质合理排序(在所有不透明物体之后,在半透明物体之前),并尽量减少水面覆盖的屏幕面积(用雾、边界遮挡)。

5.3 参数调节经验

  • 波浪(_WaveHeight,_WaveFrequency:大而缓的波浪(低频率,中高度)适合海洋;小而密的波纹(高频率,低高度)适合池塘或雨后积水。永远不要为了“明显”而把高度调得过高,否则会穿帮。
  • 法线贴图速度(_NormalSpeed:滚动太快会显得不自然,像快速流动的油。慢速滚动(0.01~0.1量级)更适合开阔水面。
  • 菲涅尔(_FresnelPower:这个值控制反射和折射的过渡锐利程度。值越大,过渡越尖锐(远处一小片很亮,脚下突然变透明)。对于平静水面,可以调高(5-8);对于有波浪的水面,调低(2-4)可以让反射更分散。
  • 交互强度(_RippleHeightScale,_RippleNormalStrength:交互效果宁弱勿强。一个轻微的涟漪法线扰动,配合适当的高光变化,比剧烈的顶点起伏看起来更真实、性能更好。

6. 从Demo到生产:需要加固的环节

把Shader拖到材质球上能跑通,只完成了10%。要放到真实项目中,还需要考虑:

  1. 与场景的融合
    • 岸边衔接:使用一张深度图或根据场景网格生成距离场,在水面Shader中让浅水区透明度增加、颜色变浅,并混合岸边的沙子/岩石纹理。
    • 水下效果:如果需要看到水下,需要处理折射。简易版可以用GrabPass抓取屏幕背景进行扭曲;高级版需要渲染水下场景。
  2. 动态天气与时间:将光照颜色、强度、天空盒反射强度等参数与你的游戏时间/天气系统联动。阴天的水面高光弱、反射暗。
  3. 多水质支持:通过Shader变体或参数控制,快速切换成“泥水”、“岩浆”、“毒液”等效果。主要是调整颜色、粘度(波浪和涟漪的衰减速度)、泡沫贴图等。
  4. 批量渲染与GPU Instancing:如果你的场景有大量相同材质的水面(如多个小水坑),确保Shader支持GPU Instancing,可以大幅降低Draw Call。
  5. Shader变体管理:一个功能丰富的水面Shader会有很多#ifdef分支(如_USE_INTERACTION_ON,_USE_REFRACTION_ON)。使用shader_feature在材质面板上明确开关,避免产生不需要的Shader变体,导致包体过大和编译时间过长。

最后也是最关键的一点:不要追求一次性实现所有效果。先从最基本的波浪+法线贴图+颜色开始,确保它在目标平台(尤其是移动端)上稳定运行在60帧。然后,像搭积木一样,一个一个地加入交互、反射、深度融合等高级特性,每加一个都要测试性能和效果。最酷的技术,如果让游戏卡成幻灯片,也失去了意义。

http://www.cnnetsun.cn/news/4177606.html

相关文章:

  • WPS Office定制版合集 使用教程:WPS Office多版本定制合集,去广告、免登录、即开即用,Office办公套件新手 5 分钟上手(2026)
  • 图与网络模型:从最短路径到网络流,数学建模核心算法解析
  • Java笔试常见易错点解析与避坑指南
  • RAG面试避坑指南:5大核心问题解析与实战技巧
  • 层次分析法(AHP)从入门到精通:多准则决策的数学建模与实践指南
  • C++模板编程:从泛型基础到编译期元编程实战
  • 异步 FIFO 为什么使用格雷码
  • 四大向量存储完整区分:pgvector / Milvus / Qdrant / Chroma
  • Java小厂面试核心考点与实战技巧
  • BiliDrive 教程:用哔哩云免费上传下载大文件,拿到 bdrive 分享链接
  • 基于LLM多智能体框架的自优化拓扑优化:打通CAD/CAE/CAM数据流
  • Claudian 避坑指南:把 Claude Code 装进 Obsidian 知识库的完整手册
  • 拼多多2027届实习生招聘:内推攻略与岗位解析
  • C++函数模板:从类型参数化到编译时泛型编程实战
  • 范畴论框架下的自我修订科学发现系统:迈向智能体AI
  • 【kv存储】实时主从同步实现与eBPF旁路转发方案
  • 深入解析Kconfig语法:从核心元素到实战应用
  • C++模板与泛型编程:从STL容器到现代概念的核心机制解析
  • 蓝桥杯国赛递增序列题解:双指针算法与竞赛思维实战
  • 车载空间音频技术解析:从BOSE虚拟环绕声看沉浸式座舱体验
  • ozz-animation 骨骼动画深度指南:从资产导入到运行时播放的完整路径
  • OpCore-Simplify 使用指南:从硬件报告一键生成 OpenCore EFI
  • OpCore-Simplify:25 分钟从硬件报告到能开机的 OpenCore EFI
  • TrueForge开源智能体框架实测:本地部署、API调用与成本优化验证
  • Vial-QMK上手30分钟:改键位、加宏、把新固件烧进键盘
  • 数学建模竞赛全攻略:从组队到论文的实战经验与思维转变
  • 老Mac免费升级macOS:OpenCore Legacy Patcher完整指南
  • 10分钟生成OpenCore EFI:OpCore-Simplify快速上手指南
  • SerenityOS:从零造一个图形化 Unix 操作系统,能学到什么?
  • 免费全景查看器 Pannellum 完整指南:一张图片三步嵌入网页 360 全景