Unity URP水体焦散效果实现:从原理到实战的完整指南

发布时间:2026/7/30 12:36:37
Unity URP水体焦散效果实现:从原理到实战的完整指南 1. 项目概述在URP中实现水的焦散效果在Unity的通用渲染管线URP中实现一个逼真的水体效果尤其是模拟光线穿过水面折射后在池底或物体表面形成的光斑图案——也就是焦散Caustics这几乎是每个追求视觉品质的项目都会遇到的挑战。你可能会发现URP自带的渲染特性里并没有一个现成的“焦散”开关而网上流传的教程要么是基于旧版内置管线要么就是直接丢给你一个看不懂的Shader代码。我自己在好几个项目里折腾过这个效果从最初简单的贴图投影到后来相对复杂的屏幕空间计算踩了不少坑。这篇文章我就来系统性地拆解一下在URP中实现水体焦散的几种主流思路从原理到实操再到性能优化和避坑指南目标是让你不仅能“抄作业”跑起来更能理解背后的门道从而根据自己项目的需求灵活调整。简单来说焦散效果的核心是模拟光线的汇聚。当平行或发散的光线比如阳光穿过不平整的水面由波浪或涟漪导致时水面就像一个个透镜使光线发生折射并聚焦在某些区域形成明亮的光斑同时其他区域则会因为光线发散而变暗。在实时渲染中我们无法像离线渲染那样精确追踪每一条光线所以需要用一些“作弊”的手段来近似模拟。对于移动端或性能要求高的项目我们可能选择用一张预计算的焦散纹理动画来投影而对于追求高质量效果的PC或主机项目则可能采用基于屏幕空间或深度纹理的动态计算方案。无论哪种理解其局限性和适用场景都至关重要。2. 核心原理与方案选型从“贴图投影”到“动态计算”在动手写Shader或搭渲染特性之前我们必须先搞清楚有哪些路可以走以及每条路的成本和收益。这决定了你最终效果的逼真度和项目能承受的性能开销。2.1 焦散效果的基本光学原理从物理上看焦散是光线折射和反射的结果。对于我们主要关注的水下焦散光线射入水中其路径是光源 - 空气与水的界面发生折射- 水下物体或池底表面形成明暗图案。水面的波动法线扰动决定了折射光线的方向变化从而在接收面上形成动态变化的光斑。实时渲染中我们通常用一个简化模型将水面在某一点的法线视为一个微小的棱镜它改变了看向水下某点通常是深度缓冲对应的世界空间位置的光线方向。计算该点应该接收多少来自“虚拟光源”的光照就形成了焦散强度。2.2 URP下的三种主流实现方案对比基于上述原理在URP中我们主要有三种实现路径复杂度和效果依次递增。方案一预烘焙动画纹理投影Projected Caustics Texture这是最经典、性能最优的方案。其思路是我们提前在DCC工具如Houdini、Blender或离线渲染器中模拟一个波动水面下的焦散图案并渲染成一段序列帧动画一张包含多个帧的纹理图集或一个视频文件。在运行时我们只需要在水体下方的接收表面如池底、岩石的Shader中根据该表面的世界坐标或屏幕坐标对这张动态纹理进行采样和播放即可。优点性能消耗极低仅增加一次纹理采样。效果艺术可控性强可以做出非常漂亮、风格化的焦散图案。缺点完全静态与场景中的动态物体如游动的鱼、角色无法产生正确的交互。焦散图案不会随水面波动形态或光源位置变化而动态改变缺乏物理真实性。当摄像机视角大幅变化时投影可能穿帮。适用场景移动端游戏、风格化项目、对性能极度敏感或焦散作为远景细节的场景。方案二基于屏幕空间深度与法线的近似计算Screen-space Caustics这是目前URP项目中平衡效果与性能的主流选择。它利用URP提供的深度纹理_CameraDepthTexture和法线纹理_CameraNormalsTexture在屏幕空间中进行计算。重建世界位置在Shader中对每个屏幕像素利用深度纹理和当前摄像机的视角-投影矩阵逆矩阵重建该像素点在世界空间中的位置。模拟光线追踪假设这个位置是池底的一点。我们从这个点“发射”一条射线反向穿过水面去探测水面的法线。实际操作中我们通常用这个点的世界坐标减去一个基于水面高度和粗略法线扰动后的“虚拟光源”方向得到一个新的UV坐标。采样噪声与合成用这个UV去采样一张或几张高频的水面噪声/法线纹理模拟水面细节通过一些算法如将法线扰动叠加到UV上来扭曲一张基础的焦散噪声图从而生成动态的、随水面波动而变化的焦散强度图。应用光照将计算出的焦散强度与场景的光照颜色、衰减等结合叠加到屏幕像素的颜色上。优点效果动态能随水面波动而变化。能与场景中所有渲染到深度/法线纹理的物体包括动态物体产生交互焦散会正确地投射在移动的物体上。实现相对高效。缺点计算在屏幕空间进行意味着被遮挡的、不在当前视图中的物体不会产生焦散。需要处理深度不连续物体边缘带来的 artifacts。对Shader编写和参数调节要求较高。适用场景绝大多数PC和主机平台的写实风格游戏需要焦散与动态物体交互的场景。方案三体素化或光子映射Voxel-based / Photon Mapping这是最接近物理模拟、效果也最震撼的方案但性能开销巨大通常用于非实时渲染或特定高端演示。思路是预先将水下空间体素化或者模拟光子从光源发射经过水面折射后在水下空间传播并缓存其能量分布。运行时直接查询缓存数据。在URP中实现极其复杂需要自定义渲染管线扩展不适用于常规游戏开发本文不做展开。实操心得如何选择我的经验是永远先从方案一开始尝试。用PS或Blender制作一个2-4帧的简单焦散图集在项目里快速验证美术风格和基本感觉。如果美术满意且性能无忧它可能就是最终方案。如果美术要求看到焦散在角色身上流动或者水面是高度动态的如瀑布下的水潭那么就必须升级到方案二。在着手方案二前请确保你的团队有足够的图形学知识和调试时间因为它会引入一系列新的问题需要解决。3. 实战基于屏幕空间的动态焦散实现详解这里我们重点拆解方案二因为它最具代表性也是大家踩坑最多的地方。我会以一个可工作的Shader Graph也附带核心HLSL代码说明为例一步步构建它。3.1 环境准备与前期设置首先确保你的URP项目配置正确。启用必要的纹理在URP Asset如UniversalRP-HighQuality中确保勾选了Depth Texture和Opaque Texture后者在某些版本也用于自定义后处理。对于更高级的法线重建可能还需要在Renderer Features中添加一个生成_CameraNormalsTexture的FeatureURP默认不生成法线纹理。创建水体与接收面准备一个简单的平面作为水面一个立方体或复杂网格作为池底/接收物体。为水面赋予一个基本的水体Shader可以是简单的法线扰动折射为接收面创建一个新的Unlit Shader Graph用于专门接收焦散。准备噪声纹理你需要至少两张纹理一张是用于模拟水面微观波动的高频噪声图比如蓝噪声或Perlin噪声平铺纹理另一张是作为焦散图案基础的焦散噪声图通常是一些明亮的光斑和条纹图案。将它们导入Unity记得将Wrap Mode设置为Repeat以便平铺。3.2 构建焦散计算Shader Graph我们将在接收物体的Shader Graph中实现计算。第一步获取屏幕空间信息与重建世界位置创建Scene Depth节点获取当前像素的深度值。创建Screen Position节点模式设为Raw得到归一化的设备坐标(NDC)。使用Depth to Position节点或手动计算。将Screen Position (Raw)和Scene Depth输入并连接当前摄像机的Camera节点获取View Projection Matrix的逆矩阵Inv输出就是该像素点在世界空间中的位置World Position。这是最关键的一步它让我们知道屏幕上这个点对应场景中的哪个实际位置。第二步模拟水面交点与光线偏移我们假设水面是一个平面Y轴为高度。计算从重建的世界位置到水面的“距离”。用一个Float参数_WaterLevel表示水面世界坐标Y值。用World Position的Y分量减去_WaterLevel得到该点在水面下的垂直深度depthUnderWater。光线的偏移量不仅与深度有关更与水面法线即波动有关。我们引入噪声来模拟法线扰动使用Texture 2D Asset节点引入准备好的高频噪声图。用World Position的XZ分量除以一个_NoiseScale参数构成UV对噪声图进行采样。将采样结果在0-1范围通过Remap节点映射到(-1, 1)范围得到两个方向的扰动值distortX和distortZ可以拆开RG通道使用。计算最终的UV偏移offset (distortX, 0, distortZ) * depthUnderWater * _DistortionStrength。这里_DistortionStrength是一个控制扰动强度的参数。原理是水下越深的点其光线穿过水面的路径越长水面波动对其最终到达位置的影响偏移就越大。第三步生成动态焦散图案使用另一个Texture 2D Asset节点引入焦散噪声图。计算采样UVcausticsUV (WorldPosition.xz offset.xz) / _CausticsScale。用世界XZ坐标加上我们计算出的偏移量再除以一个缩放参数_CausticsScale来控制焦散图案的大小。用causticsUV对焦散噪声图进行采样得到基础强度causticsBase。为了让焦散动起来我们需要让噪声“流动”。对高频噪声图的采样UV加上一个基于时间的偏移flowUV WorldPosition.xz / _NoiseScale _Time.y * _FlowSpeed。采样得到流动的噪声值用它来进一步扰动causticsUV或者与causticsBase相乘/相加从而让焦散图案产生自然的水波流动感。第四步光照整合与颜色叠加焦散强度需要结合场景光照。最简单的方法是获取主光源的颜色和强度。可以通过Lighting节点下的Main Light Color和Main Light Direction来获取。计算一个简单的光照衰减根据World Position和主光源方向可以模拟一个点光源或方向光的衰减因子。对于方向光衰减可以简化为1或基于角度的计算对于点光源如水下灯则需要计算距离衰减。最终焦散颜色finalCaustics causticsBase * _CausticsIntensity * MainLightColor * attenuation。将finalCaustics与物体原本的Albedo颜色通过Sample Texture 2D获取贴图颜色进行相加Additive混合。因为光是叠加的所以通常使用加法。你可以使用Add节点或者直接在Base Color输出前相加。3.3 关键参数解析与艺术调节一个好看的焦散效果七分靠调节。以下参数需要你在材质面板中仔细微调参数名功能描述调节技巧与常见值范围_WaterLevel水面在世界空间中的Y轴高度。确保与你的水面物体实际位置匹配。可以脚本动态绑定。_NoiseScale高频噪声纹理的平铺缩放。控制水面波动的“粒度”。值越小噪声越密集波动越细碎。通常从10-50开始尝试。_CausticsScale焦散噪声纹理的平铺缩放。控制焦散光斑的大小。值越小光斑越大。根据场景比例调整通常5-20。_DistortionStrength水面波动对光线偏移的影响强度。这是最重要的参数之一。值太小时焦散静止不动值太大会导致焦散扭曲过度、闪烁。从0.1开始慢慢增加。_FlowSpeed焦散图案流动的速度。模拟水流速度。值在0.1到1之间比较自然。_CausticsIntensity焦散的整体亮度。需要与场景光照强度匹配。白天室外可能只需要0.2-0.5昏暗水下可能需要1.0以上。_DepthFade焦散随水深度的淡出参数。通常不是直接参数但需要在计算中考虑。可以用saturate(1.0 - depthUnderWater / _MaxDepth)来实现避免在很深或很浅的地方出现不自然的硬边。注意事项性能与质量平衡纹理尺寸噪声图建议使用128x128或256x256的RGBA32格式足够细节且内存占用小。焦散图可以使用512x512。计算复杂度上述Shader Graph中每像素进行了多次纹理采样深度、噪声*2、焦散和数学运算。对于移动平台需要考虑简化例如减少一次噪声采样或使用更简单的计算代替世界位置重建。启用GPU Instancing如果你的接收面是大量相同的物体如铺地的石板确保在Shader中启用GPU Instancing可以大幅降低Draw Call。4. 常见问题、渲染缺陷与排查技巧实现动态焦散的过程就是与各种渲染异常Artifacts斗争的过程。这里记录几个我踩过的“坑”和解决方法。4.1 焦散在物体边缘闪烁或断裂这是屏幕空间方法最常见的问题。原因是物体边缘的深度值不连续我们重建的世界位置在边缘像素极不可靠导致计算出的UV发生剧烈跳变。排查用Custom Function节点输出重建后的世界位置Y值将其可视化为颜色。你会看到物体边缘的颜色是混乱的。解决深度缓冲滤波在采样Scene Depth之前可以先对深度纹理进行一个简单的滤波如使用Sample2D节点的导数或手动取周围像素平均平滑边缘的深度值。但这会带来模糊。边缘检测与淡化计算当前像素的深度与周围像素深度的差值使用DDX和DDY节点。如果差值超过某个阈值则判定为边缘将焦散强度淡出为0。这是一种更精确的解决方案。使用法线纹理辅助_CameraNormalsTexture在物体边缘通常比深度纹理更稳定。可以结合法线信息来检测边缘或提供更稳定的参考。4.2 焦散出现在水面之上或穿透物体这通常是因为深度计算没有考虑水面本身。当像素点位于水面以上时我们的Shader仍然在用“水下深度”进行计算导致错误。解决在计算depthUnderWater后立即进行判断。如果depthUnderWater 0即该点在水面以上则直接将焦散强度置零。// 在Shader Graph中可以用Condition节点实现 if (depthUnderWater 0) causticsStrength 0;4.3 焦散效果过于“平”或缺乏体积感真实的焦散在水下是有强度衰减和散射的我们的加法叠加可能显得生硬。解决深度衰减如上文所述实现一个基于depthUnderWater的指数衰减让焦散在深处变弱。散射模拟可以额外采样一张低频率的噪声乘以一个随深度变化的系数模拟水下光线散射造成的柔和光晕与尖锐的焦散光斑叠加。与体积光/雾效结合如果项目使用了体积光Volumetric Light或高度雾Height Fog确保焦散颜色能与这些后处理效果正确交互。有时需要将焦散计算提前到透明渲染阶段或者写入到自定义的渲染目标以供后处理使用。4.4 移动端性能开销过大在低端手机上屏幕空间的复杂计算可能成为瓶颈。优化策略降低分辨率计算将焦散计算渲染到一张半分辨率Half-Res的Render Texture上然后再上采样到屏幕。这对焦散这种低频变化的效果视觉损失很小但能节省约75%的像素计算量。简化计算用一张预计算的、包含简单动画的焦散纹理方案一的变体代替部分动态计算。例如用动态UV偏移采样静态纹理而不是完全实时合成。分帧计算如果焦散变化不快可以每2帧或3帧计算一次中间帧复用上一帧的结果。限制渲染范围使用一个全局的Box Collider或几何体作为焦散影响区域只有在这个区域内的物体才启用焦散Shader。可以通过脚本动态启用/禁用材质的RenderFeature或替换Shader。5. 进阶与URP渲染特性的深度集成要让焦散效果更上一层楼不能只把它当作一个孤立的后处理或物体Shader而需要考虑如何与URP的其他特性协同工作。5.1 通过Renderer Feature实现全屏焦散我们之前的实现是附着在单个接收物体的材质上。更优雅的方式是创建一个URP的Renderer Feature在渲染不透明物体之后、透明物体之前执行一个全屏的焦散Pass。优势一次计算全局应用无需为每个可能接收焦散的物体单独设置材质管理更方便。更好的后处理兼容性可以更精确地控制渲染顺序避免与SSR、Bloom等后处理冲突。易于做优化方便实施半分辨率渲染、分帧计算等全屏优化策略。实现要点创建一个ScriptableRendererFeature和对应的ScriptableRenderPass。在Pass中配置RenderTargetHandle从相机获取深度纹理和颜色纹理。编写一个HLSL Shader包含我们上述的核心计算逻辑但改为全屏三角形绘制。在Execute方法中设置渲染状态绘制全屏四边形将计算出的焦散与相机颜色纹理混合。需要将水面高度、噪声纹理等参数通过全局Shader属性Shader.SetGlobalFloat/Texture传递。5.2 与屏幕空间反射SSR和折射交互如果你的水体同时使用了SSR实现反射我们的焦散计算可能会和SSR的深度采样冲突或者导致视觉上的不协调。处理顺序通常的渲染顺序是深度预渲染 - 不透明物体含焦散计算 - SSR基于当前深度和颜色 - 透明物体如水体本身含折射 - 后处理。确保焦散已经写入到不透明物体的颜色中SSR才能捕捉到它。折射处理水体的折射Shader通常会扭曲它背后的场景颜色即包含焦散的池底。如果焦散是动态计算的这种扭曲可能会被计算两次一次在焦散生成时一次在水体折射时导致过度扭曲。一个实践技巧是在水体折射Shader中对背景颜色的采样可以稍微提前于焦散计算例如使用上一帧的深度或者对焦散区域进行一个微弱的模糊来模拟光线的散射从而减轻视觉上的冲突。5.3 多光源支持与阴影集成目前我们的例子只考虑了主光源。在有多盏灯如多个水下聚光灯的场景中焦散也应该来自多个光源。实现思路在Renderer Feature的Pass中可以遍历场景中的所有重要光源通过UniversalRenderPipeline.GetMainLight和UniversalRenderPipeline.GetAdditionalLights为每一盏灯单独计算一次焦散贡献然后叠加起来。这显然会成倍增加计算量需要严格的光源裁剪和重要性排序。阴影集成焦散也应该被阴影遮挡。一种方法是在计算焦散强度时采样阴影贴图_MainLightShadowmapTexture。如果该点处于主光源的阴影中则衰减焦散强度。这需要访问阴影纹理并正确转换坐标空间。经过这些步骤你应该能在URP中搭建出一个效果不错、性能可控的动态水体焦散系统了。记住图形效果开发没有唯一的正确答案核心在于理解每种技术背后的权衡。从最简单的投影开始逐步增加动态性并时刻用真机性能分析工具如Unity Profiler、RenderDoc来验证你的选择这才是高效的工作流。最终一个优秀的水体焦散效果应该是艺术导向和技术约束之间精妙平衡的产物。