
1. 项目概述从“一潭死水”到“波光粼粼”的实时魔法在游戏开发、数字孪生或者影视预览这些领域里一个逼真的水面效果往往是场景从“还行”到“惊艳”的关键一跃。你肯定见过那种僵硬得像蓝色塑料布一样的“死水”也一定被《神秘海域》里随着角色动作而荡漾的海面或者《赛博朋克2077》中霓虹倒映的湿润街道震撼过。这背后的核心就是实时3D水面渲染技术。今天要聊的就是如何亲手实现一个不仅看起来像水而且“行为”也像水的实时水面核心聚焦在四个最能体现水体质感的特性反射、折射、水深效果以及水岸的柔边过渡。这不仅仅是贴一张纹理那么简单它涉及到对光线物理特性的模拟、GPU编程的巧妙运用以及对性能与效果之间平衡的持续博弈。无论你是刚接触Shader编程的新手还是想优化现有水面效果的老鸟希望这篇从原理到实战的完整拆解能给你带来可以直接“抄作业”的灵感和方案。2. 核心原理拆解光线如何与水共舞在动手写代码之前我们必须先搞清楚想要模拟的物理现象到底是什么。实时水面渲染的本质是用一系列“欺骗”眼睛的数学技巧来近似模拟光与水面交互的复杂过程。2.1 反射倒影世界的构建水面反射直观理解就是能看到水面上方的景物。在离线渲染中这通常通过光线追踪精确计算。但在实时领域我们主要用两种性价比更高的方案平面反射这是最经典、最常用的实时反射技术。其核心思想是为水面单独渲染一张反射贴图。具体步骤是将摄像机“镜像”到水面平面的另一侧以这个镜像摄像机的视角重新渲染一遍场景但只渲染水面以上的部分通常通过裁剪平面实现然后将渲染结果作为纹理贴到水面上。它的优点是反射内容准确、动态能完美反映场景中移动的物体。缺点是性能开销大因为需要多渲染一次整个场景或部分场景并且对于非平静水面直接使用这张贴图会显得不真实需要结合法线扰动。屏幕空间反射这是一种更现代的技法。它不单独渲染反射贴图而是利用当前帧已经渲染到屏幕上的颜色和深度信息在屏幕空间内进行光线步进寻找反射光线的交点。SSR的优点是与场景复杂度解耦性能相对稳定且能自然处理非平面反射。缺点是在屏幕边缘或反射射线被遮挡时信息会缺失容易产生断裂需要与立方体贴图等传统技术结合来填补空白。对于追求效果与性能平衡的通用水面平面反射扰动依然是主力方案。我们会在实现部分详细展开如何设置镜像摄像机、处理裁剪和如何高效地扰动反射坐标。2.2 折射水下奥秘的窥视折射是光从一种介质进入另一种介质时发生的方向偏折。对于水面它让我们能看到水下的物体如石头、水草并且这些物体的位置看起来会发生偏移。在实时渲染中模拟折射通常比反射“取巧”。我们不会去真正计算光线的偏折路径而是采用一种称为屏幕空间折射或扭曲背景的技术。核心思路是获取当前像素点在水面下的“背景”颜色。这可以通过渲染一张水下场景的深度图或直接使用上一帧的屏幕颜色缓冲经过处理来得到。对采样这张背景贴图的UV坐标进行扰动。扰动的依据正是水面的法线贴图。法线方向决定了光线“偏折”的方向和程度。这样当水面波动时水下的景物看起来就会随之扭曲、晃动非常生动地模拟了折射效果。这里的一个关键技巧是我们需要根据视线与水面的夹角来混合反射和折射的强度。当垂直看向水面时主要看到反射如正午看湖面当视线变得平缓时折射成分会增加如站在岸边看水底。这个混合系数通常使用菲涅尔方程来近似计算。2.3 水深与透明度清澈与浑浊的掌控水不是无限透明的。真实的水体其颜色和透明度会随着深度变化。靠近岸边的浅水区清澈见底而深水区则呈现深蓝或绿色并且看不清底部。实现这个效果我们需要深度计算在Shader中获取当前水面像素点的实际深度即到摄像机的距离以及水底的深度。水底深度可以通过渲染一张单独的水底深度图或者通过射线检测等方案获得。两者的差值就是实际水深。深度颜色混合定义一个浅水颜色如淡蓝、浅绿和一个深水颜色如深蓝、墨绿。根据实际水深在这两个颜色之间进行线性或指数插值。水越深颜色越接近深水色。透明度衰减同样根据水深控制水体的不透明度Alpha值。在浅水区透明度高能清晰看到水底在深水区透明度降低模拟光线在水体中被吸收的效果。这通常用一个指数衰减函数来实现transparency exp(-depth * absorptionCoefficient)其中absorptionCoefficient是吸收系数控制水体的浑浊度。2.4 水岸柔边告别生硬的“剪纸”效果这是很多初级水面实现容易忽略但极度影响真实感的一点。在现实世界中水岸交界处由于湿润、泡沫、波浪破碎等原因从来都不是一条生硬的数学分界线。在实时渲染中我们通过多种技术组合来模拟这种柔边深度边缘淡化这是最基本的方法。计算水面网格边缘即水岸线附近的像素深度与场景深度水底或岸边物体的差值。当这个差值很小时说明水面几乎与地面接触让水体的透明度逐渐降低为0实现一个自然的淡出效果。这能有效防止水面像一张纸片“浮”在陆地上。泡沫线贴图使用一张或一套动态的泡沫噪声贴图沿着水岸线进行采样和动画。通过将泡沫颜色与水面颜色混合可以在交界处产生一条白色的、破碎的泡沫带极大地增强视觉细节和动态感。泡沫的强度同样可以由水深、岸边坡度等参数控制。距离场与波浪交互更高级的做法是使用距离场来描述海岸形状并让靠近岸边的波浪高度、频率发生改变模拟波浪拍岸、破碎的效果。这属于比较高级的领域我们会在基础实现后简要讨论其思路。3. 实战构建从网格到Shader的完整管线理论说得再多不如一行代码。接下来我们以一个基于Unity URP通用渲染管线和Shader Graph也包含部分HLSL代码思路的项目为例一步步搭建这个实时水面。即使你使用其他引擎或原生API核心思路也是相通的。3.1 场景与网格准备首先我们需要一个代表水体的网格。一个简单的平面网格Plane就是最好的起点。将其缩放至覆盖你需要的湖泊、河流区域。注意对于大型开放水域一个单一的高精度平面可能不是最优选择。常见的优化方案是使用分块网格Tileable Grid并配合视距相关的LOD层次细节。近距离使用高精度网格显示波浪细节远距离则使用低精度网格节省性能。这里我们从简单平面开始。为这个网格创建一个新的材质我们将为它编写自定义的Shader。在URP中你需要创建一个Unlit Graph或Lit Graph如果希望水体能接收光照作为起点。3.2 核心Shader节点网络剖析我们将在Shader Graph中构建主要逻辑。以下是关键节点的功能模块1. 水面波动与法线生成这是所有效果的基础。没有波动水面就是一面镜子。噪声图驱动使用两张Tileable的噪声图通常是Perlin噪声或Flow Map以不同的速度和方向滚动Time节点乘以速度系数后与UV相加。将两张滚动的噪声采样结果相加或混合产生复杂、非重复的波纹图案。高度图生成将混合后的噪声值通过一个简单的函数如乘以一个强度系数输出到顶点着色器的Position节点的Y高度通道上让网格顶点随之起伏形成几何波浪。法线图生成这是关键技巧。我们不需要为动态波浪预先烘焙法线贴图。可以直接对上述用于高度扰动的噪声图在Shader中通过DDX和DDY节点或Normal From Height节点实时计算法线。具体步骤是对扰动后的世界空间XZ坐标采样噪声得到高度值然后分别对X和Z方向求偏导DDX,DDY这两个偏导向量叉积并归一化后就能得到非常准确且动态的波浪法线。这一步计算量稍大但效果远好于使用静态法线贴图。// HLSL 概念代码说明法线计算原理 float3 worldPos IN.worldPos; float height sampleNoise(worldPos.xz); float3 dpdx ddx(float3(worldPos.x, height, worldPos.z)); float3 dpdy ddy(float3(worldPos.x, height, worldPos.z)); float3 worldNormal normalize(cross(dpdy, dpdx)); // 注意叉积顺序2. 反射贴图采样与扰动设置平面反射在Unity中你可以使用Reflection Probe立方体贴图作为快速方案但对于动态倒影需要脚本动态更新Probe或使用Planar Reflection组件URP包内提供自动帮你完成镜像摄像机的渲染和管理。这里假设我们通过脚本手动管理了一个渲染到纹理Render Texture的反射摄像机。采样与UV扰动在Shader中采样这张反射贴图。直接采样是平静水面的效果。为了模拟波浪导致的倒影扭曲我们需要用刚才计算出的法线图的XY分量或者其缩放后的值去扰动采样UV。通常只进行小幅度的扰动过大的扰动会导致倒影难以辨认。3. 折射背景获取与扭曲获取水下背景在URP中我们可以方便地通过Scene Color节点获取当前渲染的屏幕图像。但这包含水面本身。一个更精确的做法是在渲染水面之前先将不包含水面的场景渲染到一张纹理这可以通过自定义渲染队列或第二个摄像机实现。简单起见我们先用Scene Color并接受水面自身颜色可能被错误折射的微小瑕疵这在动态水面中不易察觉。扭曲采样与反射类似用法线去扰动采样Scene Color的屏幕UV坐标。但折射的扰动方向和强度可以与反射略有不同以增加真实性。4. 菲涅尔效应混合反射与折射菲涅尔方程描述了反射率随视线与法线夹角变化的规律。在Shader中我们用一个简化的Schlick近似公式就足够了Fresnel F0 (1 - F0) * pow(1 - dot(ViewDir, Normal), 5)。 其中F0是基础反射率如0.02。dot(ViewDir, Normal)越小视线越平行于水面Fresnel值越接近1即反射越强垂直看时反射最弱折射最强。 用计算出的Fresnel值作为权重线性混合反射颜色和折射颜色得到水面的基础颜色。5. 水深着色与透明度控制获取深度使用Scene Depth节点获取当前像素的屏幕深度。同时我们需要水底的深度。这可以通过一个简单的办法渲染一张只有水底地形不渲染水的深度图。更实时的做法是在Shader中通过射线步进Raymarching从水面点沿着视线方向向水下探测直到与地形相交。后者计算量大但更通用。我们先采用预渲染水底深度图的方式。计算与着色waterDepth sceneDepth - waterSurfaceDepth;。用这个waterDepth去控制颜色混合和透明度衰减。颜色lerp(shallowColor, deepColor, saturate(waterDepth / maxDepth))。透明度alpha 1 - exp(-waterDepth * absorption)。同时最终的透明度还要乘上之前基于菲涅尔的混合权重因为反射部分通常是不透明的镜面效果。6. 水岸柔边实现深度边缘检测计算waterDepth。当waterDepth小于一个阈值如0.1时我们认为是水岸边缘。泡沫叠加使用一张流动的泡沫噪声图其强度由(1 - saturate(waterDepth / foamThreshold))控制这样泡沫只出现在极浅的水域。将泡沫颜色白色以这个强度叠加到最终水色上。透明度淡化同样在边缘让最终计算出的alpha值随着waterDepth减小而线性减小到0alpha * saturate(waterDepth / fadeLength)。3.3 性能优化关键点实时渲染永远在效果和性能间权衡。水面Shader是典型的“性能杀手”区域以下几点优化立竿见影反射贴图分辨率反射用的Render Texture分辨率不必和屏幕一样高512x512或256x256往往就够了因为反射细节常被波浪扭曲。可以尝试半分辨率甚至四分之一分辨率。反射更新频率不是每一帧都需要更新反射贴图。对于移动缓慢的场景可以每2帧、3帧更新一次或者当摄像机移动超过一定角度/距离时才更新。这能大幅降低CPU和GPU负担。波浪计算优化复杂的噪声计算尤其是实时法线计算是Shader中的ALU算术逻辑单元消耗大户。考虑使用更廉价的噪声函数如Value Noise代替Perlin Noise。将法线计算从像素着色器移到顶点着色器然后在像素着色器中插值。这会导致波浪细节丢失但对于中远距离的水面是可以接受的折衷。使用一张预先计算好的、Tileable的动画法线贴图序列通过时间采样代替实时计算。这是移动端非常常用的方案。深度纹理使用确保你的管线设置中启用了深度纹理Depth Texture。在URP中勾选相应设置。避免自己用第二个摄像机去渲染深度效率很低。Shader LOD为你的水面Shader设置不同的LOD级别。当摄像机远离时自动切换到简化版本例如关闭实时法线计算、使用低分辨率反射等。4. 常见问题与调试实录即使按照步骤操作你也可能会遇到一些“坑”。这里记录几个我实际开发中常遇到的问题和解决思路。4.1 反射内容错乱或闪烁问题描述反射贴图中出现了水下的物体或者反射物体在波浪处剧烈闪烁。原因与排查裁剪平面设置错误这是最常见的原因。你的镜像摄像机的近裁剪平面应该紧贴水面并且裁剪平面Camera clipping plane应该设置为水面所在平面只渲染水面上方的部分。在Unity中可以通过Camera.projectionMatrix或设置camera.nearClipPlane并结合Shader中的Clip指令实现。务必确保裁剪平面的方向法线正确。渲染队列冲突确保水面材质本身的渲染队列在透明物体之后如Transparent而反射摄像机渲染的场景不包含这个水面本身否则会无限递归。精度问题在极浅角度观看水面时由于深度缓冲精度Z-fighting问题可能导致闪烁。可以尝试稍微抬高一点反射摄像机的位置或者为水面网格增加一个极小的深度偏移Depth Bias。解决方案仔细检查镜像摄像机的变换矩阵和投影矩阵。写一个调试脚本将反射摄像机的视锥体用线框画出来确保其位置和裁剪范围符合预期。确保水面材质不参与反射渲染。4.2 折射边缘出现硬边或颜色溢出问题描述水岸线处折射的水下景物像被剪刀裁切一样或者水的颜色“染”到了不该有的地方。原因与排查深度比较不准确用于计算水深的“场景深度”和“水面深度”可能来自不同的空间或存在误差。确保两者都在同一空间通常是观察空间或线性化的0-1深度进行比较。深度纹理的Mipmap或滤波Scene Depth节点采样时如果开启了滤波在边缘可能得到混合了背景和前景的深度值导致计算错误。尝试使用Sample Raw模式或明确指定Point滤波。柔边过渡太生硬你使用的fadeLength或foamThreshold参数太小导致过渡区间太窄。增大这些参数让透明度或泡沫的衰减更平滑。解决方案在Shader中输出waterDepth作为颜色查看临时将深度可视化检查水岸线附近的深度值变化是否平滑连续。调整柔边算法的参数并考虑使用smoothstep函数代替线性的saturate来获得更平滑的过渡。4.3 波浪法线导致反射/折射过度扭曲问题描述水面看起来油乎乎的或者反射完全看不清扭曲得太厉害。原因与排查法线扰动的强度通常是一个乘以法线XY分量的系数设置过大。解决方案反射和折射的扰动强度应该是两个独立的、较小的参数例如0.01-0.05。反射扰动可以比折射扰动更小因为人眼对倒影的稳定性更敏感。可以分别暴露两个参数到材质面板上方便微调。4.4 移动端性能急剧下降问题描述在PC上很流畅一到手机或平板就卡顿。原因与排查实时法线计算像素级实时DDX/DDY计算在移动端GPU上非常昂贵。全分辨率反射即使降低了分辨率每帧渲染一次反射贴图对移动端也是巨大负担。过高的波纹复杂度使用了多层高频率噪声叠加。解决方案降级方案为移动平台编写一个简化的Shader变体。用预制的动画法线贴图替代实时计算。关闭实时反射改用静态的立方体贴图反射探针Reflection Probe虽然动态感下降但性能极佳。优化方案将反射更新频率降到极低如每秒5次。将波浪计算完全移至顶点着色器。减少噪声叠加层数。4.5 水面与后处理效果如Bloom SSAO的交互问题问题描述开启了全屏后处理效果后水面看起来太亮或太暗或者环境光遮蔽SSAO错误地作用于水面。原因与排查后处理效果通常在全屏空间操作水面的高光反射可能被Bloom过度放大而水面的透明或特殊深度可能干扰SSAO的计算。解决方案对于Bloom调整水面的反射颜色亮度或者更高级地通过Frame Debugger工具查看Bloom提取的亮度阈值确保水面的高光区域在合理范围内。对于SSAO需要检查你的渲染管线是否正确处理了透明物体的深度。在某些管线中透明物体不写入深度缓冲可能导致其下方的SSAO计算错误。可能需要调整渲染顺序或使用自定义的深度传递机制。一个常见的Hack是让水面Shader在透明渲染时也向一个自定义的深度纹理写入一个调整后的深度值以供SSAO使用但这需要管线支持。5. 进阶思路与扩展方向当你实现了基础版本后可以考虑以下方向让水面更加栩栩如生动态潮汐线与湿润效果不仅仅是水岸柔边可以模拟波浪拍打上岸后岸边材质变深变湿的效果。这通常需要一张单独的海岸高度图或距离场在水退去时通过参数控制岸边材质的颜色和光滑度逐渐恢复干燥状态。次表面散射模拟光线在水体内部散射的效果特别是在浅水区、边缘和浪花泡沫处会产生一种柔和的光晕感。这可以通过在着色时额外采样水面下某一点沿法线方向偏移的颜色并与表面颜色混合来实现一个简化的SSS模型。焦散效果阳光透过波动的水面在水底投射出动态的光斑图案。这是一个独立而复杂的效果通常需要将水面的高度场/法线信息以某种方式“投影”到水底并作为一张光照遮罩来增强水底的光照。可以作为一个单独的、可选的渲染Pass来实现。与物理交互让角色、船只等物体与水面的交互产生涟漪。这可以通过在物体移动位置渲染一个“涟漪粒子”或写入一张全局的“扰动图”来实现。Shader采样这张全局扰动图将其叠加到本地波浪计算中就能产生动态扩散的涟漪。实现一个逼真的实时水面是一个不断迭代和权衡的过程。从最基础的反射折射开始逐步加入深度、柔边、优化再到高级特效。我的经验是先保证主干效果正确且性能达标再考虑锦上添花的细节。很多时候一个运行在60帧、效果80分的动态水面远比一个效果95分但只有30帧的幻灯片体验要好得多。希望这份详细的拆解能成为你探索水面渲染世界的一张可靠地图剩下的就交给你的创意和调试了。