ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity默认管线屏幕后处理实现晶体焦散效果

Unity默认管线屏幕后处理实现晶体焦散效果 1. 项目缘起与整体设计思路晶体焦散这个效果做3D的多少都碰过。宝石、水面、玻璃杯在光照下投射出的那种不规则亮斑就是焦散。真实感拉满但实现起来要么靠离线渲染器烘焙要么靠光线追踪硬算实时项目里想都不敢想。我这次接的一个小场景主视觉是一堆悬浮的晶体碎片美术给的概念图里晶体表面和周围地面都有那种流动的、带色散的亮纹。预算有限不可能上光追URP又因为项目历史原因没法切只能在Unity默认渲染管线里想办法。核心思路很直接不去真的算光线折射而是用屏幕后处理的方式“伪造”焦散。具体来说在相机渲染完场景之后拿深度图重建世界坐标再根据晶体在屏幕空间的位置和形状程序化地生成一层焦散纹理叠加到画面上。这样做的好处是跟场景里实际有多少晶体、晶体什么形状完全解耦性能开销基本恒定而且效果可控性强想调颜色、强度、流动速度都是改几个参数的事。为什么选屏幕后处理而不是其他方案我对比过几种常见做法。一种是给晶体模型挂一个投射贴图的投影器但投影器在默认管线里对透明物体和复杂形状支持很差晶体碎片一多就穿帮。另一种是用粒子系统模拟光斑但粒子很难贴合晶体边缘看起来像贴纸。屏幕后处理的好处是它工作在图像空间晶体在屏幕上是什么样焦散就能跟着变成什么样天然对齐。代价是它没有真正的物理遮挡关系晶体背面的焦散也会被算出来不过对于这种风格化场景这点瑕疵完全能接受。整个方案拆成三块第一块是深度图获取和世界坐标重建这是所有屏幕空间效果的基础第二块是焦散图案的生成我用的是程序化噪声加Voronoi的组合模拟晶体内部那种多面体折射的碎裂纹理第三块是屏幕空间的位置映射和混合把焦散图案按照晶体的屏幕投影位置铺上去再跟原画面做叠加。下面逐个拆开讲。注意默认渲染管线里拿深度图需要相机挂脚本手动设置不能像URP那样直接声明。这个坑后面会细说。2. 深度图获取与世界坐标重建的实操细节屏幕后处理的第一步永远是拿到深度。Unity默认管线里相机默认是不生成深度纹理的你得在相机脚本里显式设置camera.depthTextureMode DepthTextureMode.Depth。这行代码不写Shader里采样_CameraDepthTexture就是一片白很多人卡在这里半天找不到原因。设置完之后在Shader里用sampler2D _CameraDepthTexture声明采样得到的深度值经过LinearEyeDepth转换才是真正的相机空间深度。世界坐标重建有两种常见做法。一种是拿深度值乘上相机的远平面参数和方向向量反推另一种是用相机的逆投影矩阵。我选的是后者因为默认管线里unity_CameraInvProjection这个内置矩阵直接可用代码更干净。具体做法是在顶点着色器里算出屏幕UV在片元着色器里用UV构造一个NDC坐标z分量取深度值然后乘上逆投影矩阵得到相机空间坐标再乘相机到世界的矩阵得到世界坐标。这里有个细节NDC的z在Unity里是反的默认管线用的是OpenGL风格的投影z范围是-1到1但深度纹理里存的是0到1转换的时候要记得z depth * 2 - 1再取反。float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float linearDepth LinearEyeDepth(depth); float4 ndc float4(i.uv * 2 - 1, depth * 2 - 1, 1); ndc.y * -1; // 默认管线UV原点在左下NDC原点在中心y要翻转 float4 worldPos mul(unity_CameraToWorld, mul(unity_CameraInvProjection, ndc)); worldPos / worldPos.w;这段代码我调了差不多一个下午才跑通主要坑在y翻转和w除。默认管线的UV坐标系跟NDC坐标系原点位置不一样不翻转y的话重建出来的世界坐标上下颠倒焦散会跑到画面另一边去。w除是因为逆投影矩阵出来的还是齐次坐标不除的话坐标值会随深度剧烈变化看起来像焦散在缩放。重建出世界坐标之后还需要一个东西世界空间法线。焦散的强度跟表面朝向关系很大朝上的地面焦散最明显朝侧面的墙壁就弱很多。默认管线里没有GBuffer法线可读我的做法是用深度图做屏幕空间导数来近似法线。具体是用ddx和ddy对重建出的世界坐标求偏导叉乘得到法线方向。这个方法在平坦表面上很准在边缘处会有噪点但焦散本身是模糊的这点噪点看不出来。float3 dpdx ddx(worldPos.xyz); float3 dpdy ddy(worldPos.xyz); float3 normal normalize(cross(dpdy, dpdx));实操心得ddx和ddy在移动端部分GPU上精度不够如果项目要上手机建议把法线计算简化成直接用世界坐标的y分量做权重效果差一点但稳定。3. 程序化焦散图案的生成与参数调优焦散图案是整个效果的核心。我试过三种方案第一种是直接用美术画的贴图好处是可控坏处是重复感强晶体一多就看出是同一张图在复制第二种是纯噪声用Perlin噪声叠几层但出来的图案太“软”没有晶体那种锐利的折射感第三种是Voronoi加噪声调制最终选的就是这个。Voronoi的原理简单说就是把空间划分成一个个细胞每个细胞里有一个特征点像素到最近特征点的距离决定了图案。晶体折射产生的亮斑恰好就是这种多边形的碎裂纹理所以Voronoi天然适合。我在Shader里实现了一个简化版的3D Voronoi输入是世界坐标乘上一个缩放系数输出是到最近特征点的距离。为了让图案有流动感特征点的位置用时间做偏移每个细胞偏移方向不同看起来就像光在晶体内部游走。float3 voronoi(float3 p) { float3 n floor(p); float3 f frac(p); float minDist 1.0; for (int x -1; x 1; x) { for (int y -1; y 1; y) { for (int z -1; z 1; z) { float3 g float3(x, y, z); float3 o hash3(n g); o 0.5 0.5 * sin(_Time.y * _FlowSpeed 6.2831 * o); float3 r g o - f; float d dot(r, r); minDist min(minDist, d); } } } return minDist; }这个循环是27次迭代在桌面端没问题移动端要砍成2D版本或者减少迭代次数。hash3函数我用的是常见的正弦哈希网上能搜到这里不展开。_FlowSpeed控制流动速度我实测下来0.3到0.5之间比较自然太快像沸腾太慢像静止。拿到Voronoi距离之后还要做几步处理才能变成焦散。第一步是取反再取幂让距离近的地方亮、远的地方暗幂次控制亮斑的锐利程度我用的4到8之间。第二步是叠一层高频噪声做扰动模拟晶体表面的微小凹凸对光线的散射噪声强度0.1左右就够了多了会显得脏。第三步是色散真实焦散边缘会有彩虹色我用的是把RGB三个通道分别用不同的缩放系数采样Voronoi再合并成本很低但效果提升明显。float causticR pow(1 - voronoi(worldPos * _Scale float3(0.01, 0, 0)), _Sharpness); float causticG pow(1 - voronoi(worldPos * _Scale), _Sharpness); float causticB pow(1 - voronoi(worldPos * _Scale - float3(0.01, 0, 0)), _Sharpness); float3 caustic float3(causticR, causticG, causticB) * _CausticColor;色散偏移量不能太大0.01到0.02之间大了就变成红绿蓝三张图错位看起来像故障艺术而不是焦散。_CausticColor我一般用偏青蓝的色调因为晶体折射的光通常带冷色具体看场景氛围调。常见问题Voronoi在物体边缘会产生明显的接缝因为特征点跨细胞边界时距离不连续。解决办法是在边缘处用噪声做一次混合或者干脆接受这个瑕疵因为焦散本身是模糊的接缝在最终画面里几乎看不出来。4. 屏幕空间映射与最终混合的完整流程图案有了接下来要解决“贴到哪里”的问题。最直接的想法是把焦散图案按世界坐标铺满整个场景但这样地面、墙壁、天空全都有焦散显然不对。焦散应该只出现在晶体附近和晶体投影到的表面上。我的做法是用晶体的屏幕空间包围盒做遮罩。具体来说在C#脚本里每帧计算所有晶体在屏幕上的投影范围把它们的屏幕坐标和半径打包成一个数组传给Shader。Shader里对每个像素判断它是否落在任何一个晶体的影响范围内是的话计算一个衰减权重不是的话权重为0。这个判断在片元着色器里做晶体数量控制在20个以内性能没问题超过的话要考虑用一张遮罩图代替。// C#端每帧更新 Vector4[] crystalData new Vector4[crystals.Count]; for (int i 0; i crystals.Count; i) { Vector3 screenPos cam.WorldToScreenPoint(crystals[i].position); float radius crystals[i].radius * cam.pixelHeight / (2 * Vector3.Distance(cam.transform.position, crystals[i].position) * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad)); crystalData[i] new Vector4(screenPos.x / cam.pixelWidth, screenPos.y / cam.pixelHeight, radius / cam.pixelHeight, crystals[i].intensity); } causticMaterial.SetVectorArray(_CrystalData, crystalData); causticMaterial.SetInt(_CrystalCount, crystals.Count);半径的计算用的是相似三角形晶体世界半径乘上屏幕高度除以两倍距离乘tan半FOV这是标准的屏幕投影公式。intensity是每个晶体单独的强度可以让大的晶体焦散更亮小的暗一些增加层次。Shader端拿到这些数据后对每个像素遍历数组算像素屏幕坐标到晶体屏幕坐标的距离除以半径得到归一化距离再用smoothstep做衰减。多个晶体重叠时取最大值而不是累加避免过曝。float mask 0; for (int i 0; i _CrystalCount; i) { float4 data _CrystalData[i]; float2 diff i.uv - data.xy; diff.x * _ScreenParams.x / _ScreenParams.y; // 修正宽高比 float dist length(diff) / data.z; float w smoothstep(1.0, 0.0, dist) * data.w; mask max(mask, w); }宽高比修正这步很容易漏。不修正的话在非正方形屏幕上焦散会变成椭圆晶体看起来像被压扁了。_ScreenParams是Unity内置变量x是宽度、y是高度除一下就行。最后一步是混合。焦散是叠加型效果用加法混合最自然但纯加法在亮部会过曝所以我用的是screen混合模式公式是1 - (1 - a) * (1 - b)既有叠加的亮度又不会爆掉。混合强度用一个全局参数控制方便美术调。float3 sceneColor tex2D(_MainTex, i.uv).rgb; float3 finalColor 1 - (1 - sceneColor) * (1 - caustic * mask * _GlobalIntensity); return float4(finalColor, 1);_GlobalIntensity我一般给0.5到1.5之间看场景整体亮度。场景本身暗的话可以给高一点亮的话给低一点不然焦散会被淹没。实操心得屏幕后处理的执行顺序很重要。焦散应该在所有不透明物体渲染完之后、透明物体之前执行。在默认管线里把脚本挂在相机上在OnRenderImage里做Graphics.Blit默认就是在不透明之后。如果场景里有透明物体焦散会被透明物体遮挡这是正确的行为。5. 性能优化与常见问题排查实录这套方案在桌面端跑60帧很轻松但有几个性能点要注意。第一个是Voronoi的27次循环这是最大的开销。优化方法有两个一是把3D Voronoi降成2D用世界坐标的xz平面做输入y方向用噪声模拟视觉上差别不大但循环降到9次二是预计算一张Voronoi贴图运行时只做采样但会失去流动感。我最终用的是2D版本加一层y方向噪声性能提升差不多三倍。第二个是晶体数组的遍历。20个晶体每个像素遍历20次在1080p下就是4000万次循环虽然每次很简单但累积起来也不少。优化方法是用一张屏幕空间的遮罩图代替实时计算每帧在CPU端把晶体投影画到一张小尺寸RenderTexture上Shader里只采样一次。这张图分辨率可以很低256x256就够因为焦散本身是模糊的。我实测这个优化能再省一半的GPU时间。第三个是深度图的精度。默认管线里深度图是24位或16位取决于平台设置。16位深度在远距离会有明显的精度问题重建出的世界坐标会抖动焦散跟着闪。解决办法是在Quality设置里把深度纹理精度调到24位或者把相机远平面调近一点。我一般把远平面设在100到200之间对大多数场景够用。下面整理几个我实际踩过的坑和排查方法问题现象可能原因排查方法解决方案焦散全屏都是没有遮罩晶体数组没传进去或数量为0在Shader里输出_CrystalCount看是否为0检查C#端数组赋值和SetVectorArray调用焦散上下颠倒NDC的y没翻转把ndc.y * -1注释掉对比默认管线必须翻转y焦散随相机移动而滑动世界坐标重建错误固定相机移动物体看焦散是否跟随物体检查逆投影矩阵和w除焦散边缘有硬接缝Voronoi细胞边界不连续放大看接缝处用噪声混合或降低锐利度移动端焦散闪烁深度精度不够或ddx精度低在移动端输出深度值看是否跳变提高深度精度或改用法线近似焦散颜色偏灰色散偏移太小或颜色空间不对把色散偏移调大对比确认项目颜色空间是Linear还是Gamma还有一个容易被忽略的点颜色空间。如果项目用的是Linear颜色空间Shader里计算出的颜色需要经过LinearToGammaSpace转换才能正确显示否则焦散会偏暗偏灰。默认管线里Graphics.Blit不会自动做这个转换得手动加。我一开始没注意调了半天颜色都不对后来在最后输出前加了一行转换就正常了。finalColor LinearToGammaSpace(finalColor);注意如果项目是Gamma空间这行要删掉否则会过亮。判断方法是在Player Settings里看Color Space选项。6. 效果扩展与个人经验总结这套焦散做出来之后我又基于它扩展了几个变体。一个是把Voronoi换成六边形网格模拟冰晶的规则折射适合雪地场景。另一个是加一层屏幕空间的模糊让焦散在远处自动变模糊模拟景深效果近处锐利远处柔和层次感更好。还有一个是把焦散图案同时叠加到晶体自身上让晶体内部也有光斑流动看起来更通透。模糊这层我是用两次降采样加高斯模糊做的在焦散图案生成之后、混合之前执行。降采样到四分之一分辨率模糊半径随深度线性增加远处模糊半径大、近处小。这样既省性能又符合视觉习惯。代码不复杂就是标准的Graphics.Blit链这里不展开。关于参数调优我个人的经验是_Scale控制焦散密度值越大亮斑越小越密一般给2到5之间_Sharpness控制亮斑锐利度4到8之间太低像雾太高像噪点_FlowSpeed0.3到0.5配合场景氛围调_GlobalIntensity0.5到1.5看场景亮度。这几个参数我建议做成材质面板上的滑块让美术自己调程序不要写死。最后分享一个调试技巧在Shader里加一个_DebugMode开关0是正常输出1是只输出遮罩2是只输出焦散图案3是只输出世界坐标法线。这样出问题的时候可以快速定位是哪一层的问题比盲猜效率高很多。这个开关在最终发布版本里关掉就行不影响性能。这套方案我从接需求到调完差不多花了一周其中三天在调深度重建和屏幕映射两天在调焦散图案剩下两天做优化和扩展。如果让我重新做一遍我会先把深度重建和遮罩跑通确认坐标对齐了再去做图案这样能少走很多弯路。焦散这种效果坐标对齐是根基根基不稳后面怎么调都是白费。
返回列表