公司动态
Unity风格化水体着色器开发:从Shader Graph到性能优化的完整实践
1. 项目概述与核心价值最近在社区里看到不少朋友对Unity中的水体效果制作感兴趣尤其是那种风格化、非写实但又极具表现力的水面。无论是想做一款独立游戏还是为项目增添一抹亮色一个好看的水体着色器往往是提升场景氛围感的关键。我自己在多个项目中折腾过水体从最开始的简单平面贴图到后来用Shader Graph和手写Shader实现复杂交互踩过的坑不少也积累了一些心得。这个“Unity水体着色器项目教程”的核心就是带你从零开始构建一个属于自己的、可高度定制的风格化水体Shader。我们不会追求物理级的精确模拟那需要复杂的流体力学计算对大部分项目来说性价比不高。我们的目标是用相对可控的性能开销实现视觉上足够吸引人、且能与游戏世界互动的“好看”的水。为什么选择风格化水体首先它更可控。写实水体对光照、反射、折射、焦散等要求极高稍有不慎就会显得假或者在不同设备上表现不一致。风格化水体则允许我们进行艺术化的夸张和简化比如更鲜艳的颜色过渡、更明显的波纹轮廓反而能形成独特的美术风格。其次它对性能更友好。我们可以通过Shader Graph这样的可视化工具或者编写结构清晰的URP/内置管线Shader来精确控制每一个计算步骤避免不必要的开销。最后它更容易与游戏性结合。你可以轻松地调整参数来实现“毒液沼泽”、“魔法圣泉”或“卡通海洋”等不同主题的效果这是写实水体难以快速实现的。本教程适合有一定Unity和Shader基础的开发者。如果你刚接触Shader别担心我会尽量把原理讲透并提供完整的节点图或代码。你将学到的不只是一个可复制粘贴的Shader更是一套构建和思考水体效果的方**。我们会涵盖水面基础外观颜色、波纹、动态效果波浪、泡沫、交互响应物体划过涟漪、岸边泡沫以及性能优化要点。最终你会得到一个功能全面、参数可调、适用于移动端和PC端的风格化水体着色器。2. 核心思路与方案选型Shader Graph vs 手写代码在动手之前我们必须做一个关键决策使用Unity的Shader Graph可视化工具还是手写HLSL/ShaderLab代码这两种方式各有优劣选择哪一种取决于你的项目需求、团队技能和个人偏好。2.1 Shader Graph方案详解Shader Graph是Unity官方推出的可视化着色器编辑工具它让你通过连接节点来构建着色器无需直接编写代码。对于水体这种涉及多层混合、噪声扰动和数学运算的效果Shader Graph的优势非常明显。优势分析快速迭代与直观调试这是最大的优点。你可以实时看到每个节点如噪声图、正弦波、颜色混合对最终输出的影响。调整一个参数效果立竿见影极大地加快了美术和程序之间的沟通效率。对于风格化效果这种快速反馈循环至关重要。降低入门门槛你不需要深刻理解HLSL语法和复杂的矩阵运算也能通过组合预设节点创造出复杂的效果。节点库中提供了大量常用的函数如DDX/DDY求偏导用于边缘泡沫封装了底层复杂性。更好的团队协作技术美术TA可以独立负责Shader效果的创作和调整无需程序员深度介入。生成的.ShaderGraph文件也易于版本管理。局限与考量性能黑盒节点虽然方便但每个节点背后都对应着GPU指令。如果不清楚节点内部的实现很容易在不知不觉中连接出性能昂贵的图。例如连续使用多个高精度噪声采样或者在顶点着色器中做复杂的全屏纹理读取。灵活性天花板对于一些极度定制化或需要访问底层渲染管线如自定义深度纹理处理、复杂的几何着色器变形的高级效果Shader Graph可能无法直接实现或者实现起来非常绕。版本兼容性Shader Graph本身和节点功能会随着Unity版本更新而变化项目升级时可能需要调整图形。对于我们的风格化水体项目我强烈推荐初学者和大多数项目优先使用Shader Graph。它能让我们专注于艺术表现和效果逻辑而非语法细节。本教程也将以Shader GraphURP管线作为主要实现路径进行讲解。2.2 手写Shader代码方案手写Shader意味着直接编写HLSL代码嵌入到ShaderLab框架中。这是一条更硬核但也更强大的路径。优势分析极致性能与控制你可以精确控制每一行代码进行极致的优化。比如手动合并数学运算、使用更廉价的函数近似、精细管理精度half/fixed/float。对于需要部署在低端移动设备上的项目这可能是唯一的选择。无限灵活性没有任何限制。你可以实现任何GPU支持的计算编写复杂的顶点动画、曲面细分或者与引擎其他部分进行深度集成。深入理解原理通过手写Shader你会被迫理解渲染管线的每一个阶段理解法线、深度、屏幕坐标等概念的本质这对长期成长非常有帮助。挑战与成本学习曲线陡峭HLSL和ShaderLab有一套自己的语法和规则调试困难一个拼写错误或语义错误就可能导致整个Shader失效或显示为洋红色错误状态。迭代速度慢每次修改都需要编译且无法直观看到中间步骤的结果调试依赖经验或额外的可视化输出。维护成本高代码可读性依赖于编写者的习惯对于团队交接不如Shader Graph直观。我的建议是如果你目标是快速出效果、进行原型验证或者团队中有技术美术请使用Shader Graph。如果你正在开发一款对性能有极端要求如开放世界手游的项目或者你本人希望深入图形学底层那么可以从Shader Graph原型开始然后将关键部分用优化后的手写代码替换或者直接挑战手写。在本教程中我会先基于Shader Graph构建完整效果同时在每个核心模块讲解其背后的数学原理和等效的HLSL代码思路让你既能快速实现又能理解本质为未来的优化和进阶打下基础。3. 构建水体着色器核心模块我们将把风格化水体的构建分解为几个核心模块像搭积木一样逐个实现。最终将它们有机地组合起来。主要模块包括基础颜色与透明度、波纹与法线模拟、动态波浪系统、边缘泡沫与交互涟漪。3.1 模块一基础颜色与深度渐变水体的第一印象来自于它的颜色。我们通常需要两种颜色浅水区颜色和深水区颜色。实现原理是利用摄像机渲染的深度图Depth Texture或通过顶点位置计算的水体深度。在Shader Graph中的实现步骤获取深度信息添加一个Scene Depth节点。在URP中你需要确保摄像机启用了“Depth Texture”选项。此节点输出当前像素到相机的距离。计算局部水深水的表面是一个平面网格。我们需要知道水面下某一点到水底或某个参考平面的距离。一种常见方法是获取水面网格上该点的世界空间Y坐标。使用Scene Position节点从深度重建的世界位置获取水底对应点的世界空间Y坐标。更简单的方法是假设水底是一个平面可以用一个平面方程来近似。两者相减得到近似水深。更实用的风格化做法是直接用水面网格的局部坐标或世界坐标的Y值变化或者采样一张预制的深度渐变图来模拟。颜色插值使用Lerp线性插值节点。将计算或模拟出的“水深”值归一化到0-1范围作为插值因子T。将Color A设为浅水色如青绿色Color B设为深水色如深蓝色。当T0浅时输出AT1深时输出B中间平滑过渡。实操要点与技巧深度图精度移动设备上深度图精度可能不足在远距离会导致水深处颜色 banding色带。可以结合使用噪声对插值因子进行轻微扰动打破规则的渐变。艺术化控制不要局限于线性插值。你可以使用一个Gradient渐变节点用“水深”去采样一个自定义的多色渐变这样可以做出更富艺术感的过渡比如在中等深度加入一些紫色或青色。性能Scene Depth节点需要额外的纹理采样。如果性能吃紧可以考虑用顶点颜色或第二套UV来存储预绘制的深度信息但这会减少动态性。注意在URP中正确获取深度和法线纹理需要在Renderer Asset中勾选相应选项并在Shader Graph中通过Universal Render Pipeline下的Camera节点组获取这是新手常踩的坑。3.2 模块二波纹与法线模拟平静的水面看起来不真实。我们需要模拟水面的细微波纹这是通过扰动法线贴图来实现的。法线贴图决定了光线如何反射从而产生凹凸感。实现方案准备法线贴图你需要一张或一套水波纹法线贴图。可以去资源商店购买或使用Substance Designer等工具生成。风格化水体可以使用手绘的、对比更强烈的法线贴图。多重纹理采样与滚动单一层的法线贴图会显得很假。标准做法是使用至少两层法线贴图以不同速度和方向滚动然后叠加混合。在Shader Graph中使用两个Sample Texture 2D节点采样同一张或不同的法线贴图。使用Time节点输出一个随时间变化的值结合Tiling Offset节点或直接计算新的UVNewUV UV Time * Speed。为两层设置不同的Speed向量如(0.03, 0.05) 和 (0.05, -0.02)。使用Blend Normals节点将两个采样结果混合。这个节点内部会处理法线向量的归一化等操作比简单相加更准确。强度与缩放控制通过一个Float参数控制法线贴图的强度影响凹凸感通过Tiling参数控制波纹的密度。背后的数学原理法线贴图存储的是每个像素的法线向量XYZ分量映射到RGB通道。采样后需要从切线空间转换到世界空间在Shader Graph中通常由主节点图的Fragment Normal Space设置自动处理或使用Transform节点。混合法线本质上是将两个向量相加后重新归一化确保结果仍是单位向量。实操心得避免重复感即使两层法线以不同速度滚动长时间观察仍可能发现重复图案。解决方法引入第三层非常缓慢移动的大尺度噪声法线或者使用世界空间坐标而非UV作为采样坐标的一部分这样水网格在世界中的不同位置会采样法线图的不同部分有效打破重复。性能权衡每多采样一次纹理就多一次纹理读取开销。对于移动端可以考虑使用RGBA通道打包两张不同尺度的法线到一张图上减少采样次数。风格化调整想要更卡通化的波纹可以尝试在混合法线后使用Normalize和Power节点对法线的某个分量进行强化让高光更集中。3.3 模块三动态波浪系统除了细节波纹我们还需要宏观的波浪运动让水面有起伏感。这通常通过顶点动画来实现。顶点动画实现方法正弦波叠加最简单的波浪是正弦波。在顶点着色器阶段Shader Graph中在Vertex主节点前插入Custom Function或使用Position节点修改根据顶点的世界XZ坐标和时间计算一个Y方向的偏移量。公式可以简化为OffsetY amplitude * sin( dot(waveDirection, worldPos.xz) * frequency time * speed )其中amplitude是波高waveDirection是波浪传播的方向向量frequency是波频。Gerstner波这是更高级、更真实的波浪模型。它不仅垂直移动顶点还会水平移动使得波峰更尖、波谷更平形成典型的波浪形状。实现比正弦波复杂但效果更好。在Shader Graph中实现完整的Gerstner波计算量较大通常需要手写Custom Function节点。多层波叠加和法线一样使用多个不同参数方向、速度、幅度的正弦波或Gerstner波叠加可以产生非常自然复杂的海面效果。在Shader Graph中的操作步骤添加一个Custom Function节点将其设置为“Vertex”阶段。在函数体内编写HLSL代码计算顶点偏移。输入通常是顶点的世界位置、时间、以及波浪参数。将计算出的偏移量加到原始的世界位置Y分量上然后输出新的位置。注意事项网格细分度顶点动画的效果严重依赖于水面网格的细分程度。如果网格面数太少波浪会显得很生硬有棱角。确保你的水面网格有足够多的顶点。性能开销顶点动画在顶点着色器中执行顶点数越多计算量越大。对于大范围的水面需要权衡波浪细节和性能。可以考虑使用曲面细分着色器Tessellation动态增加近处水面的顶点密度但这属于高级特性。与碰撞体同步如果游戏玩法需要物体与波浪互动如船随波逐流那么CPU端也需要有一套相同的波浪计算逻辑来驱动物理碰撞体的位置否则会出现视觉和物理不同步的“鬼畜”现象。这通常需要将波浪函数在C#中复现一遍。3.4 模块四边缘泡沫与交互涟漪这是让水体“活”起来的关键。边缘泡沫指水体与岸边或其他物体交界处产生的白色泡沫。交互涟漪则是物体如角色、雨滴落入水中时激起的环形波纹。边缘泡沫实现基于深度/高度的泡沫这是最常用的方法。计算水面与岸边的深度差或高度差。使用Scene Depth获取水底深度与水面深度比较在浅水或深度突变的地方即岸边产生泡沫。在Shader Graph中可以用Smoothstep节点来处理。Smoothstep(edge0, edge1, x)会在x处于edge0和edge1之间时产生一个平滑的0-1过渡。我们将深度差作为x就可以在特定深度范围内生成平滑的泡沫遮罩。添加泡沫纹理将上述计算出的泡沫遮罩一个0-1的值用来采样一张泡沫噪声纹理。泡沫纹理通常是黑白的白色代表泡沫。然后与水体颜色进行叠加混合如使用Add或Screen混合模式。动态变化让泡沫纹理随时间缓慢流动或变化可以使泡沫看起来更自然。同样可以通过偏移UV来实现。交互涟漪实现交互涟漪的实现相对复杂因为它需要外部输入信息。常见的有两种思路渲染纹理Render Texture方法这是最灵活、效果最好的方法。在场景中创建一个隐藏的、从顶部向下看的摄像机渲染一张“涟漪图”。任何物体进入水面就在这张图对应位置画一个圆环。然后在水体Shader中采样这张Render Texture根据其颜色值代表涟漪的高度或强度来扰动水面顶点或法线。优点可实现任意形状、任意位置的复杂交互支持涟漪扩散、叠加、衰减。缺点需要额外的渲染通道和纹理内存有性能开销实现复杂度高。顶点距离场方法在Shader中计算水面每个顶点到水中交互物体如一个球体的世界空间距离。根据距离应用一个类似正弦波衰减的偏移函数来模拟涟漪。优点实现简单无需额外渲染开销。缺点只能处理少量、形状简单的交互物体如点或球难以处理复杂形状和多个物体的叠加且涟漪无法在GPU上持久化和扩散。对于风格化水体项目我建议先实现边缘泡沫它能立刻提升水体的可信度。交互涟漪可以根据项目实际需求如果只是需要简单的雨滴或零星落物效果可以用距离场方法快速实现如果需要复杂的船只尾迹、角色涉水效果则需要规划使用Render Texture方案。4. 整合与高级效果合成现在我们已经有了所有核心模块基础色、法线波纹、动态波浪、边缘泡沫。接下来需要将它们像图层一样合成起来并加入一些“点睛之笔”的效果。4.1 合成管线搭建在Shader Graph中合成主要在Fragment阶段进行。一个典型的合成流程如下基础颜色由深度渐变模块输出Base Color。应用高光与反射高光使用Specular相关节点输入我们混合好的法线、视角方向、光照方向和一个光滑度参数计算高光强度。风格化水体可以使用更简单的高光模型比如直接用Fresnel Effect菲涅尔节点来模拟边缘高光。反射添加Scene Color节点可以获取屏幕空间反射即反射周围场景。但这在URP中通常需要开启Opaque Texture。更风格化的做法是使用一个 Cubemap 来模拟天空或环境的反射。将法线和视角向量输入Reflection Vector节点然后采样 Cubemap。颜色叠加将高光和反射的结果以Add相加或Screen滤色模式叠加到基础颜色上。添加泡沫将边缘泡沫模块输出的泡沫颜色通常是白色或浅灰色同样以Add或Screen模式叠加到上一步的结果上。泡沫的强度由泡沫遮罩控制。透明度处理水通常是半透明的。我们可以根据深度或视角来调制透明度。使用Fresnel Effect节点是一个好方法让水面的边缘视线与水面夹角小更透明中心视线与水面夹角大更不透明。将菲涅尔因子连接到主节点图的Alpha输入。Shader Graph主节点图设置关键点Surface Type设置为Transparent。Blend Mode选择Alpha或Premultiply取决于你的混合需求。通常Alpha更通用。确保Depth Write为Off但Depth Test通常保持Less Equal以确保水能正确被前景物体遮挡。4.2 加入风格化“点睛”效果为了让水体更具风格和个性可以尝试以下效果焦散光斑风格化版真实的焦散模拟非常昂贵。我们可以用取巧的办法在水底投影一张扭曲的光斑纹理。利用水面的法线信息对一张光斑噪声图的UV进行扰动然后以Add模式叠加到水底颜色上并使其随着水面波纹移动。水下扭曲模拟光线通过水面发生的折射。在Shader Graph中这可以通过扰动Scene Color或背景纹理的采样UV来实现。用我们之前计算的法线向量的XY分量乘以一个很小的强度系数加到屏幕UV上然后再去采样场景颜色。内部散射Subsurface Scattering模拟光线进入水体内部后散射产生的柔和光晕常用于表现清澈的浅水或生物发光的水体。一个简化的实现是根据视角方向与光照方向的夹角在水的薄边缘用菲涅尔或深度判断添加一层光晕色。性能优化合成策略不是所有效果都需要同时开启。你应该根据目标平台和摄像机距离设计一个LOD细节层次系统。例如高配/近景开启所有效果高质量法线、波浪顶点动画、屏幕空间反射、焦散、扭曲。中配/中景关闭屏幕空间反射用Cubemap代替简化波浪计算减少波的数量降低法线贴图采样次数。低配/远景只保留基础颜色和一层简单的滚动法线甚至用顶点颜色代替深度计算。在Unity中可以通过创建多个不同复杂度的Shader变体或者在一个Shader中使用#if编译指令来开关不同功能块并通过脚本根据距离动态切换材质的关键参数如_WaveAmplitude设为0来关闭波浪动画。5. 性能优化与常见问题排查一个再漂亮的水体如果导致帧率骤降也是不合格的。这里分享一些关键的优化经验和排错技巧。5.1 性能瓶颈分析与优化Overdraw过度绘制半透明物体如水需要从后往前渲染并且每个像素可能被绘制多次。这是水体最大的性能杀手。优化尽可能减少水体的覆盖面积。使用多个小水面代替一个巨大的水面网格。仔细调整透明度的衰减让远处的水更快变得完全透明Alpha0减少不必要的像素计算。纹理采样法线贴图、深度图、场景颜色图、噪声图……每次采样都有成本。优化合并纹理。例如将R、G、B通道分别存储不同尺度/用途的噪声。使用纹理数组或图集。对于移动端确保所有纹理尺寸合理如法线贴图用512x512而非2048x2048并开启Mipmap。计算复杂度复杂的数学运算如多个正弦波、噪声函数在片段着色器中执行数百万次。优化将能移到顶点着色器的计算就移上去如主波浪计算。用更廉价的函数近似比如用frac和三角波近似正弦波。使用half精度在Shader Graph中注意节点的精度设置代替float进行计算。动态分支Shader中的if语句在GPU上效率可能很低因为同一波束warp内的所有线程需要执行所有分支。优化尽量避免在片段着色器中使用动态if。用step、smoothstep或lerp等数学函数来替代条件判断。例如代替if (depth 0.1) { foam 1; }使用foam 1 - smoothstep(0.05, 0.1, depth);。5.2 常见问题与解决方案速查表下表整理了开发水体着色器时最常遇到的“坑”及其解决方法。问题现象可能原因排查与解决方案水体显示为洋红色Missing MaterialShader编译错误。1. 检查Shader Graph是否有未连接的必需节点端口。2. 检查Custom Function节点中的HLSL代码是否有语法错误。3. 查看Unity控制台的错误信息通常会有详细提示。水体边缘有硬边或锯齿深度测试ZTest或透明度混合问题。1. 确保渲染队列Render Queue设置正确透明物体通常 2500。2. 尝试调整Depth Write和Depth Test。对于复杂透明叠加有时需要ZWrite Off和ZTest LEqual。3. 使用Offset参数将水面稍微推向摄像机一点解决深度冲突Z-fighting。法线/波纹看起来“扁平”或没效果法线向量未正确转换或光照模型不对。1. 在Shader Graph主节点图中检查Normal输入是否连接了正确的法线向量并确认Normal Space设置通常是切线空间。2. 确保场景中有有效的光源Directional Light。3. 检查法线贴图导入设置是否正确Texture Type应为Normal map。边缘泡沫不显示或显示位置错误深度计算错误或泡沫遮罩参数不对。1. 确认摄像机已启用深度纹理Camera组件 - Rendering - Depth Texture。2. 在Shader Graph中使用Custom Function输出中间计算的深度差或泡沫遮罩到Emission通道可视化调试看数值范围是否正确。3. 调整Smoothstep节点的edge0和edge1参数它们控制了泡沫出现的深度范围。波浪动画导致水面“撕裂”或闪烁顶点动画精度不足或波浪频率过高导致锯齿。1. 增加水面网格的细分程度。2. 在波浪计算中对世界坐标进行缩放如worldPos.xz * 0.1降低空间频率。3. 检查时间变量_Time是否过大导致采样间隔太大。移动设备上帧率很低过度绘制或计算过于复杂。1. 使用Unity的Frame Debugger或GPU Profiler定位瓶颈。2. 实施LOD系统简化远处或次要水体的Shader。3. 减少纹理采样次数和渲染分辨率如果可行。4. 考虑将水体渲染到一张低分辨率的Render Texture上再上采样到屏幕。水下扭曲效果错乱折射UV偏移量过大或采样了错误的纹理。1. 将折射强度参数_RefractionStrength调小从0.01开始尝试。2. 确保用于折射UV偏移的法线向量是切线空间的且只使用其XY分量。3. 确认采样的Scene Color对应的是扭曲前的背景。5.3 调试技巧实录当效果不如预期时系统化的调试至关重要分步隔离法这是最有效的方法。在Shader Graph中新建一个空白输出每次只连接和测试一个功能模块比如只测试颜色渐变只测试法线。确认该模块工作正常后再接入下一个。这能快速定位问题模块。可视化调试充分利用Shader Graph的Preview窗口和将中间变量输出到Emission或Albedo的能力。例如将计算出的“水深”值直接作为颜色输出你就能直观地看到深度图的分布是否正确。参考对比法在Asset Store找一个评价较高的免费水体资源导入到你的项目中。将它的Shader和你的并排比较查看它的参数设置、节点连接常常能发现自己在思路或细节上的疏漏。数值范围检查很多问题源于数值超出预期范围。确保你的参数如时间、坐标、深度差在一个合理的量级内。善用Clamp或Saturate节点将值限制在0-1之间观察变化。最后别忘了在不同光照条件白天、黑夜、室内、不同角度俯视、平视下测试你的水体。一个健壮的水体着色器应该在各种环境下都能保持视觉上的协调和稳定。