
在Unity项目里做视觉表现最让人头疼的往往不是模型面数或者贴图精度而是那种看起来差一口气的氛围感。尤其是做水体、宝石、魔法水晶这类材质时很多开发者第一反应是去堆高光、加反射探针结果画面还是像塑料。焦散Caustics就是典型的氛围放大器——它指的是光线经过折射或反射后在物体表面或内部形成的那些流动的亮斑。真实世界里你在游泳池底看到的那种波光粼粼的网状光纹就是焦散。而在Unity默认渲染管线Built-in Render Pipeline下想不依赖任何第三方插件、纯靠屏幕后处理Screen Space Post-Processing做出伪晶体焦散效果其实是一条性价比极高的路子。它不需要你改材质Shader去逐个物体处理而是在相机渲染完场景后统一对屏幕图像做一次光斑叠加性能可控、接入简单特别适合中小型项目或者美术资源已经定型、不方便大改的情况。这篇内容我会把整套思路拆开讲透从原理到代码、从参数到踩坑适合已经熟悉Unity基础操作、想进阶做视觉表现的开发者参考。1. 为什么伪晶体焦散要选屏幕后处理这条路1.1 焦散的本质与伪字的含义先把概念理清楚。真正的焦散是光线追踪级别的物理现象光线穿过透明介质比如水面、玻璃、晶体时发生折射折射后的光线在接收面上汇聚或发散形成亮度不均的光斑。要在实时渲染里做真焦散通常得用光子映射或者屏幕空间光线追踪代价极高Unity默认管线根本扛不住。所以伪晶体焦散的核心思路是不去模拟真实的光线折射路径而是用一张程序化生成的、随时间流动的光斑图案叠加到屏幕上。这张图案看起来像是晶体内部折射出来的光纹视觉上足够以假乱真但计算量只是几次纹理采样和数学运算。这就是伪字的全部含义——骗过眼睛而不是骗过物理。那为什么强调晶体因为晶体焦散和普通水面焦散不一样。水面焦散是柔和的、大面积的波纹晶体焦散更锐利、更有几何感常常呈现出多边形、棱角分明的网状结构光斑边缘清晰还会有色散带来的轻微彩虹边。这个视觉特征决定了我们生成光斑图案时不能用简单的正弦波而要用带棱角感的噪声或者Voronoi类图案。1.2 屏幕后处理相比逐物体Shader的优势很多人第一反应是给晶体模型写一个Shader在片元里算焦散。这条路不是不行但有几个硬伤。第一焦散是投射到接收面上的效果接收面可能是地面、墙壁、其他物体你没法保证每个接收面都用同一个Shader。第二逐物体处理意味着每个物体都要跑一遍焦散计算draw call和overdraw都会上去。第三美术调整起来很痛苦改一个参数要重新编译所有相关材质。屏幕后处理则完全反过来整个场景渲染完之后只对最终图像做一次全屏处理。不管场景里有多少物体、多少材质焦散都是统一叠加的。参数调整实时生效美术在Scene视图里就能看到效果。代价是它无法精确区分哪些物体该有焦散、哪些不该但这个可以通过深度图或者遮罩来补救后面会讲。提示屏幕后处理适合全局氛围型效果不适合需要精确物体级控制的场景。如果你的项目里只有一块晶体需要焦散且接收面固定逐物体Shader可能更省如果是多个晶体、动态光源、复杂场景屏幕后处理是更优解。1.3 默认管线的后处理接入方式Unity默认管线做屏幕后处理标准做法是挂一个脚本在相机上实现OnRenderImage(RenderTexture src, RenderTexture dest)方法。这个回调在相机渲染完场景后触发src是当前屏幕图像你把它用Graphics.Blit配合自定义材质处理一遍输出到dest即可。这里有个关键点默认管线的OnRenderImage是同步的且会打断SRP Batcher之类的优化但在Built-in管线里这本来就是标准流程不用担心。另外要注意相机的allowHDR和allowMSAA设置HDR会让亮度范围更大焦散叠加时更容易过曝需要配合色调映射MSAA则可能和某些后处理冲突建议在质量设置里统一。还有一点OnRenderImage要求相机不能是Camera.main之外的临时相机随便挂最好用一个专门的组件管理并且注意[ExecuteInEditMode]或者[ImageEffectAllowedInSceneView]特性让效果在Scene视图也能预览这对美术调参极其重要。2. 焦散光斑图案的生成逻辑2.1 用Voronoi噪声模拟晶体棱角感前面说了晶体焦散要有几何感。我试过几种噪声Perlin太柔和像水波不像晶体Simplex好一点但还是偏软最后选了Voronoi细胞噪声因为它天然产生多边形边界正好对应晶体棱角。Voronoi的原理是在空间里撒一堆随机点每个像素取离它最近的那个点输出距离值。距离场的等值线就是多边形网格。在Shader里实现一个简化版Voronoi通常用floor把UV划分成格子每个格子放一个随机点然后采样周围3x3或5x5个格子找最近距离。这个计算量在屏幕后处理里是可以接受的因为分辨率固定且可以降采样。具体到焦散我们不是直接用Voronoi的距离值而是对它做一次取反幂次处理让亮斑集中在细胞边界上。公式大致是caustic pow(1.0 - dist, power)power控制亮斑的锐利程度值越大边缘越细越亮。2.2 双层图案叠加制造流动感单层Voronoi是静止的看起来像贴图。要流动得让图案随时间偏移。但直接平移UV会显得很假像整张图在滑动。我的做法是叠加两层不同频率、不同方向的Voronoi每层用不同的速度偏移然后相乘或相加。两层叠加的好处是高频层提供细节和锐利感低频层提供大块的光斑分布两者速度不同视觉上就产生了内部在涌动的错觉。这跟水面焦散用两层法线扰动是同一个思路。代码上大概是这样float2 uv1 uv * _Scale1 _Time.y * _Speed1; float2 uv2 uv * _Scale2 _Time.y * _Speed2; float c1 Voronoi(uv1); float c2 Voronoi(uv2); float caustic pow(1.0 - c1, _Power1) * pow(1.0 - c2, _Power2);注意_Speed1和_Speed2不要成整数倍关系否则会出现明显的周期性重复看起来像在循环播放。我一般用0.13和0.07这种互质感强的数。2.3 色散效果的廉价实现晶体焦散有个迷人的细节边缘会有轻微的颜色分离红绿蓝三通道的亮斑位置略微错开这是色散。真色散要按波长算折射率太贵。廉价做法是对三个通道分别用略微不同的UV偏移采样同一张焦散图然后合成。float r SampleCaustic(uv float2(0.002, 0.0)); float g SampleCaustic(uv); float b SampleCaustic(uv - float2(0.002, 0.0)); float3 causticColor float3(r, g, b);偏移量控制在0.001到0.003之间太大就变成明显的重影了。这个技巧成本极低但视觉提升很明显尤其是亮斑边缘。3. 把焦散叠加到屏幕上的完整流程3.1 后处理脚本的骨架搭建先写C#脚本。核心是OnRenderImage里面创建一个材质把参数传进去然后Graphics.Blit。[ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class CrystalCausticsPostProcess : MonoBehaviour { public Shader causticsShader; [Range(0, 2)] public float intensity 1.0f; public Color causticColor Color.white; public float scale 8.0f; public float speed 0.5f; public float power 4.0f; public float dispersion 0.002f; private Material _mat; Material Mat { get { if (_mat null causticsShader ! null) _mat new Material(causticsShader) { hideFlags HideFlags.HideAndDontSave }; return _mat; } } void OnRenderImage(RenderTexture src, RenderTexture dest) { if (Mat null) { Graphics.Blit(src, dest); return; } Mat.SetFloat(_Intensity, intensity); Mat.SetColor(_CausticColor, causticColor); Mat.SetFloat(_Scale, scale); Mat.SetFloat(_Speed, speed); Mat.SetFloat(_Power, power); Mat.SetFloat(_Dispersion, dispersion); Graphics.Blit(src, dest, Mat); } void OnDisable() { if (_mat ! null) DestroyImmediate(_mat); } }几个细节hideFlags设为HideAndDontSave防止材质在场景里残留OnDisable里销毁材质避免内存泄漏[ExecuteInEditMode]让效果在编辑器里实时可见。3.2 Shader里的叠加与混合模式Shader部分顶点着色器就是标准的全屏三角形或Quad。片元着色器里先采样原图再算焦散最后混合。混合模式很关键。焦散是加光所以用加法混合最自然finalColor sceneColor caustic * intensity。但纯加法容易过曝尤其是场景本身很亮的时候。我的做法是加一个软阈值或者用sceneColor * (1 caustic)这种乘法式叠加让亮部不会爆掉。fixed4 frag(v2f i) : SV_Target { fixed4 scene tex2D(_MainTex, i.uv); float caustic ComputeCaustic(i.uv); float3 causticCol _CausticColor.rgb * caustic * _Intensity; scene.rgb causticCol * (1.0 - scene.rgb); // 软加法亮部抑制 return scene; }(1.0 - scene.rgb)这一项让焦散在暗部更明显、亮部被压制符合物理直觉也避免了过曝。3.3 用深度图限制焦散的作用范围纯屏幕后处理有个问题焦散会叠加到所有物体上包括天空盒、UI、近处的人物。这显然不对。解决办法是采样深度图根据深度决定焦散强度。在默认管线里相机可以生成深度纹理。给相机挂脚本时开启camera.depthTextureMode | DepthTextureMode.Depth;然后在Shader里声明sampler2D _CameraDepthTexture采样得到深度值。float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float linearDepth Linear01Depth(depth); float depthMask smoothstep(_NearFade, _FarFade, linearDepth); caustic * depthMask;这样就能让焦散只在某个深度区间内出现比如只作用于地面附近天空和近景自动淡出。_NearFade和_FarFade根据场景尺度调一般近处0.1、远处0.8左右。注意深度图在默认管线里是额外的一次渲染会有一点性能开销。如果项目对性能极度敏感可以用一个全局遮罩贴图代替但灵活性差很多。4. 参数调优与视觉打磨的实战经验4.1 强度、尺度、速度的黄金配比参数调优是这类效果最耗时间的部分。我踩过的坑是一开始把intensity拉到2以上结果整个画面像蒙了一层白雾焦散完全糊掉。后来总结出一套经验值。参数推荐范围作用调过头的后果Intensity0.3 ~ 0.8焦散整体亮度画面发白、失去对比Scale6 ~ 15光斑密度太小像噪点太大像大色块Speed0.2 ~ 0.8流动速度太快像闪烁太慢像静止Power3 ~ 6亮斑锐利度太大只剩细线太小糊成一片Dispersion0.001 ~ 0.003色散强度太大出现明显重影这套值是我在1080p和2K分辨率下反复试出来的4K下Scale可以适当调大因为像素密度高了同样的UV尺度下光斑会显得更细。4.2 让焦散呼吸起来低频调制静止的焦散看久了会腻。我加了一个低频的正弦调制让整体强度随时间缓慢起伏模拟光源能量波动或者晶体内部能量涌动。float breath 0.8 0.2 * sin(_Time.y * _BreathSpeed); caustic * breath;_BreathSpeed取0.3到0.6之间周期大概10到20秒人眼能感觉到变化但不会觉得在闪。这个细节很小但加上之后整个效果活了很多。4.3 和场景光照的协调焦散是叠加光所以场景本身的明暗基调决定了焦散的表现。如果场景是暗调的比如洞穴、夜晚焦散可以大胆用亮色对比强烈非常出彩。如果场景是明亮的白天焦散就得收敛否则会显得脏。我的建议是焦散颜色不要用纯白用带一点环境色偏向的颜色。比如场景是冷色调的冰晶洞穴焦散用淡青色0.7, 0.9, 1.0如果是暖色调的魔法水晶用淡金色1.0, 0.9, 0.7。这样焦散和场景是融合的而不是贴上去的。另外如果场景里有实时点光源可以让焦散的颜色跟随主光源颜色变化通过脚本每帧传_CausticColor这样光源变色时焦散也跟着变一致性更好。5. 性能表现与常见问题排查5.1 分辨率与降采样的取舍全屏后处理的性能瓶颈主要在像素填充率。焦散计算里有Voronoi每个像素要采样周围多个格子成本不低。在移动端或者低端PC上直接全分辨率跑可能会掉帧。优化手段是降采样把焦散图案渲染到一张半分辨率甚至四分之一分辨率的RenderTexture上然后再上采样叠加到全屏。因为焦散本身是柔和的、低频的图案降采样带来的模糊反而让效果更自然。int w src.width / 2; int h src.height / 2; RenderTexture rt RenderTexture.GetTemporary(w, h, 0); Graphics.Blit(src, rt, Mat, 0); // 降采样算焦散 Graphics.Blit(rt, dest, Mat, 1); // 上采样合成 RenderTexture.ReleaseTemporary(rt);实测在1080p下降采样到半分辨率能省大约40%的GPU时间视觉上几乎看不出差别。5.2 深度图失效与边缘溢出的处理深度图在某些情况下会失效比如相机是正交投影、或者平台不支持深度纹理。这时候_CameraDepthTexture采样出来是固定值深度遮罩就废了。排查方法是先在Shader里把深度值直接输出成灰度图看看有没有正常的深度渐变。另一个常见问题是物体边缘的深度溢出。因为深度图是低分辨率的物体边缘的深度值会有跳变导致焦散在边缘出现硬边或者闪烁。解决办法是用SAMPLE_DEPTH_TEXTURE配合一点偏移做软过渡或者干脆把深度遮罩的过渡区间拉宽用smoothstep的软边掩盖掉。5.3 和UI、后处理栈的冲突如果项目里用了UGUIUI是渲染在相机之后的OnRenderImage默认不会影响UI这是好事。但如果UI是用World Space Canvas做的就会被焦散影响需要额外处理。另外如果项目里已经挂了其他后处理比如Bloom、Color Grading要注意执行顺序。OnRenderImage的执行顺序由脚本的[ImageEffectOpaque]特性和组件顺序决定。焦散一般放在Bloom之前、色调映射之后比较合适这样焦散的光斑能被Bloom进一步柔化效果更梦幻。提示多个后处理脚本挂在同一相机上时执行顺序是从下往上组件列表里靠下的先执行。如果发现焦散被其他效果覆盖了调整组件顺序即可。6. 从能用到好看的进阶思路6.1 用遮罩贴图控制焦散分布全局均匀的焦散还是有点假。真实场景里焦散应该集中在晶体附近的地面上远处逐渐消失。可以用一张世界空间投影的遮罩贴图或者用晶体的位置在屏幕空间算一个距离场让焦散强度随距离衰减。简单做法是在脚本里传入晶体在世界空间的位置Shader里把屏幕UV反投影到世界空间算到晶体的距离做衰减。这个计算稍微复杂但效果提升明显焦散会跟着晶体走。6.2 结合法线扰动做折射错觉如果接收面有法线贴图比如水面、地面可以让焦散的UV根据法线做扰动模拟光线被表面起伏折射的效果。这样焦散就不是平铺在屏幕上而是贴合表面起伏立体感强很多。float3 normal tex2D(_CameraNormalsTexture, i.uv).xyz; float2 distort normal.xy * _DistortStrength; float caustic ComputeCaustic(i.uv distort);这需要相机生成法线纹理DepthTextureMode.DepthNormals开销比纯深度大一点但值得。6.3 多晶体叠加与颜色区分场景里如果有多个不同颜色的晶体可以让每个晶体贡献一层焦散颜色不同叠加起来非常华丽。实现上就是多传几组参数Shader里循环计算。注意层数不要超过3层否则性能吃不消而且视觉上会乱。我在一个冰洞场景里试过三层主晶体淡青、副晶体淡紫、环境晶体淡蓝叠加后整个洞穴的冰面像活了一样美术看了直接拍板。这个效果如果逐物体做工作量是屏幕后处理的十倍不止。整套方案跑下来在1080p、中端显卡上开启降采样后GPU开销大概在1.5到2毫秒之间对于氛围提升来说这个代价完全可以接受。真正花时间的不是写代码而是调参数——同样的代码参数差一点效果就是廉价特效和高级质感的区别。我的习惯是先把Scale和Power定下来这两个决定了光斑的基本形态然后再微调Intensity和Speed最后加色散和呼吸。每次调完记得在不同光照环境下都看一眼暗场景和亮场景的表现可能天差地别。