ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

从零实现可交互水面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 URPUniversal Render Pipeline现在是Unity的主流推荐。它要求你使用Shader Graph可视化或者HLSL代码URP Shader Library模板。如果你的项目是URP那么传统内置管线的Surface Shader写法大概率不兼容。本文的原理相通但具体实现如获取深度图、摄像机参数的方式需要按URP的API来。Unity HDRPHigh Definition RP追求电影级画质自带更复杂的水体系统。如果你用HDRP通常不建议从头造轮子而是基于其Lit Shader模板或Visual Effect Graph进行扩展。Three.js / WebGL在GLSL中实现核心原理一致但需要注意WebGL 1.0/2.0的特性支持如浮点纹理、Mipmap采样以及如何通过JavaScript传递交互数据如鼠标点击位置。我的建议是如果是学习或快速原型用Unity内置管线或URPShader Graph最容易出效果。如果是严肃的移动端项目URP是更稳妥的选择因为其对移动平台优化更好。2.2 交互式水面的核心组件拆解一个看起来“活”的水面通常由以下几层效果叠加而成我们需要逐一攻破几何变形顶点动画让平面“起伏”起来产生波浪。这是性能消耗的大头。法线与光照根据变形后的表面计算法线与灯光交互产生明暗、高光镜面反射这是质感的关键。颜色与透明度模拟水的颜色、深度衰减岸边浅、深处蓝、透明度。反射与折射高级效果可能需要渲染反射贴图RenderTexture或使用屏幕空间反射SSR。交互扰动响应外部力如角色入水、子弹击中、鼠标点击产生局部涟漪。我们按从易到难的顺序先实现一个有基础波浪和光照的静态水面再加入交互式涟漪。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 片元着色器加入颜色与基础光照水的颜色不是单一色它通常由三部分组成底色水体的基本颜色。深度色越深的地方颜色越深通常偏蓝/绿。菲涅尔效应视线与水面夹角越小看远处水面反射越强夹角越大看脚下水面折射透出底部越强。我们先实现一个简化的版本// 在片元着色器函数中例如 frag fixed4 frag (v2f i) : SV_Target { // 1. 基础颜色 fixed4 baseColor _BaseColor; // 2. 简易深度模拟用世界空间Y轴或视角深度做一个渐变 // 这里假设水面在Y0水下物体有深度 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。我们采样一张水的法线贴图并让其滚动来模拟高频细节。获取法线贴图在Asset Store搜索“Water Normal Map”或使用类似下图样式的纹理。确保纹理导入设置中Texture Type设为Normal map。在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游戏逻辑和GPUShader通信最灵活的方式之一是使用一张RenderTexture作为“交互信息贴图”。这张贴图的每个像素可以记录水面某个对应位置的“扰动高度”或“扰动时间”。实现步骤创建交互贴图在脚本中创建一张RenderTexture例如128x128初始化为纯黑0。将它传递给Shader作为一个Texture2D属性如_InteractionMap。在脚本中绘制交互当发生交互时如鼠标点击、角色入水在脚本中通过Graphics.Blit或CommandBuffer以一个“涟漪笔刷”纹理一个从中心白向边缘黑衰减的圆形去绘制叠加到这张_InteractionMap上。绘制的位置UV由交互的世界XZ坐标转换到水面的局部UV坐标得到。在Shader中解码交互在片元着色器中采样_InteractionMap。采样到的值比如r通道代表该点当前的“扰动强度”。用这个强度值去进一步偏移顶点高度或扰动法线。衰减与清除每一帧在将_InteractionMap传递给Shader前先用一个衰减因子如0.98去乘整个贴图让旧的涟漪逐渐消失。也可以定期完全清除。这是一个简化的C#脚本示例用于Unitypublic 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 性能开销大头分析顶点动画每个顶点每帧都要做正弦波计算。对策控制水面网格的面数。对于大水面使用LODLevel of Detail远处用低模简单Shader近处用高模完整效果。法线贴图采样特别是多张法线贴图混合和滚动。对策将多层法线贴图合并到一张RGBA贴图中如R通道存第一层G通道存第二层减少采样次数。在移动端考虑只使用一层。RenderTexture交互每帧对RT进行读写和衰减。对策降低RT分辨率64x64有时也够用。使用ComputeShader进行高效的并行衰减和叠加计算比Graphics.Blit更快。复杂光照计算每像素多次的dot、pow、normalize操作。对策在URP中利用其内置的光照函数。在Built-in管线中考虑使用简化的光照模型如半兰伯特。反射/折射实时渲染反射贴图将场景渲染到纹理是性能杀手。对策使用平面反射Planar Reflection技术但只反射近处重要物体或者使用屏幕空间反射SSR但注意硬件支持度和瑕疵。对于风格化或移动项目经常用一张**天空盒立方体贴图Cubemap**模拟反射性价比最高。5.2 常见平台问题排查清单OpenGL ES 3.0 / WebGL 1.0 兼容性问题ddx/ddy偏导数函数可能不被支持或行为不一致这会影响法线贴图、边缘光、交互梯度计算。排查检查Shader编译日志。如果报错考虑使用预计算的法线贴图或禁用依赖ddx/ddy的效果。RenderTexture格式问题移动设备上RenderTextureFormat.RFloat可能不支持。排查使用SystemInfo.SupportsRenderTextureFormat进行检查。回退方案是使用ARGBHalf或ARGB32格式但精度会降低。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%。要放到真实项目中还需要考虑与场景的融合岸边衔接使用一张深度图或根据场景网格生成距离场在水面Shader中让浅水区透明度增加、颜色变浅并混合岸边的沙子/岩石纹理。水下效果如果需要看到水下需要处理折射。简易版可以用GrabPass抓取屏幕背景进行扭曲高级版需要渲染水下场景。动态天气与时间将光照颜色、强度、天空盒反射强度等参数与你的游戏时间/天气系统联动。阴天的水面高光弱、反射暗。多水质支持通过Shader变体或参数控制快速切换成“泥水”、“岩浆”、“毒液”等效果。主要是调整颜色、粘度波浪和涟漪的衰减速度、泡沫贴图等。批量渲染与GPU Instancing如果你的场景有大量相同材质的水面如多个小水坑确保Shader支持GPU Instancing可以大幅降低Draw Call。Shader变体管理一个功能丰富的水面Shader会有很多#ifdef分支如_USE_INTERACTION_ON,_USE_REFRACTION_ON。使用shader_feature并在材质面板上明确开关避免产生不需要的Shader变体导致包体过大和编译时间过长。最后也是最关键的一点不要追求一次性实现所有效果。先从最基本的波浪法线贴图颜色开始确保它在目标平台尤其是移动端上稳定运行在60帧。然后像搭积木一样一个一个地加入交互、反射、深度融合等高级特性每加一个都要测试性能和效果。最酷的技术如果让游戏卡成幻灯片也失去了意义。
返回列表