Unity冰材质渲染:从折射原理到次表面散射的Shader实现
1. 项目概述为什么冰纹理折射是个“技术活”在Unity里做冰材质乍一听好像挺简单——不就是调个透明度和折射嘛。但真上手做过的朋友都知道这东西坑特别多。你照着网上的基础折射Shader做出来要么像个玻璃球要么像个塑料块怎么看都跟“冰”那种清冽、坚硬、内部带着细微浑浊和裂纹的感觉不沾边。这就是为什么“冰纹理折射”这个标题背后其实是一个综合性的渲染技术挑战。它远不止是调用一个refract函数那么简单而是需要你理解光线在非均匀介质中的复杂行为并用Shader语言把它模拟出来。核心的难点在于真实的冰不是一块完美的透明晶体。它内部有微小的气泡、杂质、应力形成的裂纹这些都会导致光线在穿过时发生散射、折射和反射的混合。我们看到的冰那种“白蒙蒙”的边缘和内部浑浊感主要就来自于次表面散射。而冰表面那种锐利、高光的质感又需要精确的菲涅尔反射和高光反射。所以一个合格的冰纹理折射Shader本质上是一个基于物理的、融合了折射、散射、反射的复合材质模型。这个项目适合已经对Unity Shader有基础了解写过一些表面着色器或URP Lit Shader但想挑战更复杂、更视觉效果驱动的材质开发的开发者或技术美术。如果你对dot(N, V)、切线空间、立方体贴图这些概念不再陌生那么通过这个项目你能系统性地掌握如何将一种复杂的物理现象分解成可编程的渲染步骤并最终获得一个可以直接用在游戏项目里的、参数可调的冰材质。下面我们就从最核心的光学原理开始一步步拆解实现过程。2. 核心光学原理与Shader设计思路拆解在动手写代码之前我们必须先搞清楚要模拟什么。把冰放在光线下观察你会发现它的视觉效果由几个关键部分叠加而成。2.1 光线与冰相互作用的三大组件1. 表面反射这是最直接的部分。光线打到冰的表面一部分会立刻被反射出去遵循反射定律。冰的表面通常很光滑所以这部分反射是清晰的镜面反射。它的强度遵循菲涅尔效应——视线与表面法线夹角越大即掠射角反射越强。这就是为什么冰块的边缘看起来总有一圈亮边。2. 折射与透射这是“冰纹理折射”这个标题最直接对应的部分。光线进入冰内部时由于空气和冰的折射率不同空气约1.0冰约1.31其传播方向会发生偏折这就是折射。折射方向由斯涅尔定律决定。光线在冰内部传播一段距离后从另一侧穿出再次发生折射最终进入我们的眼睛形成了透射图像。这部分贡献了冰块后方景物的扭曲效果。3. 次表面散射这是让冰看起来“像冰”而不是“像玻璃”的关键。光线进入冰内部后并不会乖乖地直线传播。它会撞到微小的冰晶、气泡、杂质或裂纹从而被随机地散射到各个方向。一部分散射光会重新从入射表面附近穿出这使得冰看起来内部有一种柔和的、乳白色的光泽尤其是在较厚的部位和边缘。散射的强度和颜色分布通常偏向蓝白色决定了冰的浑浊度和“寒意”。2.2 基于URP管线的Shader框架选择明确了要模拟的效果接下来要选择实现路径。对于这样一个复杂的材质我强烈建议在Unity的通用渲染管线URP下进行开发。URP提供了更现代、更高效的Shader编写框架HLSL并且内置了对复杂光照模型更好的支持。我们有两条主要路径Shader Graph可视化编程优点是快速、直观适合原型设计和美术人员。对于冰材质这种节点连接复杂的逻辑用Shader Graph可以清晰地看到数据流。网络上也有很多相关的Graph案例。手写HLSL代码优点是性能控制精准、灵活性极高可以实现更复杂的数学运算和自定义光照模型。这也是本次拆解我们将重点遵循的路径因为它能让你更深刻地理解每一个计算步骤。无论选择哪条路我们的Shader主体结构都将继承自URP的Lit着色器模板因为它已经为我们搭建好了复杂光照计算的基本框架如主光源、附加光源、阴影等。我们需要做的是修改和扩充其核心的光照函数。设计思路总结我们的Shader将计算三种颜色贡献值——反射颜色、折射颜色、散射颜色然后根据物理规律主要是能量守恒和菲涅尔系数将它们混合。最终的颜色输出可以表示为FinalColor Fresnel * ReflectionColor (1 - Fresnel) * (RefractionColor SubsurfaceScatteringColor)这是一个高度简化的模型但构成了我们实现的骨架。3. 构建Shader基础框架与关键函数让我们从创建一个最基础的URP Shader开始并逐步植入冰材质所需的核心功能模块。3.1 创建URP Lit Shader并定义输入输出首先在Unity中创建一个新的Shader文件选择“Universal Render Pipeline/Lit”作为模板。我们将其命名为“IceRefractionShader”。清空模板中不必要的部分我们先来定义关键的结构体。// 在Properties块中定义暴露给材质面板的参数 Properties { [Header(Base Parameters)] _BaseColor(Base Color, Color) (0.8, 0.9, 1.0, 0.5) // 冰的基础色调通常偏蓝白 _Smoothness(Smoothness, Range(0, 1)) 0.95 // 高光光滑度 [HDR] _SpecColor(Specular Color, Color) (1,1,1,1) [Header(Refraction)] _IndexOfRefraction(Index of Refraction (IOR), Range(1.0, 2.0)) 1.31 // 冰的折射率 _RefractionDistortion(Refraction Distortion, Range(0, 0.1)) 0.05 // 折射扭曲强度 _RefractionDepth(Refraction Depth, Range(0, 1)) 0.5 // 采样深度偏移 [Header(Subsurface Scattering)] _ScatterColor(Scatter Color, Color) (0.7, 0.8, 1.0, 1.0) _ScatterStrength(Scatter Strength, Range(0, 5)) 1.0 _ScatterFalloff(Scatter Falloff, Range(0.1, 10)) 2.0 _ScatterDistortion(Scatter Distortion, Range(-1, 1)) 0.2 [Header(Noise and Details)] _NoiseTex(Noise Texture (R), 2D) white {} _NoiseScale(Noise Scale, Range(0.01, 0.5)) 0.1 _NoiseStrength(Noise Strength, Range(0, 0.5)) 0.1 }接下来是关键的Varyings结构体我们需要在其中加入用于屏幕空间折射和散射计算所需的数据struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; // 世界空间顶点位置 float3 normalWS : TEXCOORD1; // 世界空间法线 float3 viewDirWS : TEXCOORD2; // 世界空间视线方向 float4 screenPos : TEXCOORD3; // 用于屏幕空间采样的齐次坐标 float2 uv : TEXCOORD4; // 如果需要可以添加雾效或光照贴图的坐标 };在顶点着色器中我们需要计算并传递这些数据Varyings vert(Attributes input) { Varyings output; // ... 标准的顶点变换Object-World-Clip空间 VertexPositionInputs vertexInput GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput GetVertexNormalInputs(input.normalOS); output.positionCS vertexInput.positionCS; output.positionWS vertexInput.positionWS; output.normalWS normalInput.normalWS; output.viewDirWS GetWorldSpaceViewDir(vertexInput.positionWS); output.screenPos ComputeScreenPos(output.positionCS); // 关键计算屏幕空间坐标 output.uv TRANSFORM_TEX(input.uv, _NoiseTex); return output; }3.2 实现屏幕空间折射采样折射效果的核心是扭曲冰块背后的场景图像。在实时渲染中最常用且高效的方法是利用屏幕空间折射。我们通过修改当前像素在屏幕纹理通常是相机的Opaque Texture即不透明物体渲染结果中的采样坐标来模拟扭曲。首先我们需要在片元着色器中获取屏幕UVfloat2 GetRefractionUV(float4 screenPos, float2 distortionOffset) { // 将齐次裁剪空间坐标转换为标准的0-1屏幕UV float2 uv screenPos.xy / screenPos.w; // 应用扭曲偏移 uv distortionOffset; // 可选对UV进行钳制防止采样到屏幕边缘之外可能导致视觉瑕疵 uv clamp(uv, 0.001, 0.999); return uv; }那么关键的distortionOffset如何计算它应该基于法线方向和视线方向。计算折射方向使用斯涅尔定律。在Shader中我们可以用内置的refract函数。float3 refractVector refract(-viewDirWS, normalize(normalWS), 1.0 / _IndexOfRefraction);注意refract函数要求入射方向指向表面所以我们取-viewDirWS。这里用1.0 / IOR是因为光线是从外部空气IOR≈1进入冰IOR1。将折射方向投影到屏幕平面我们只关心折射导致的横向偏移。可以将折射向量转换到切线空间或者简单地使用其XY分量在视图空间或世界空间并乘以一个强度系数。// 一种简单有效的方法使用视图空间的折射向量XY分量 float3 viewSpaceRefract mul((float3x3)UNITY_MATRIX_V, refractVector).xyz; float2 distortionOffset viewSpaceRefract.xy * _RefractionDistortion;添加细节扰动为了让折射效果更自然避免出现均匀的扭曲我们可以用一张噪声纹理对偏移进行扰动。float2 noiseUV input.uv * _NoiseScale _Time.y * 0.1; // 让噪声动起来 float noise tex2D(_NoiseTex, noiseUV).r * 2.0 - 1.0; // 将0-1映射到-1到1 distortionOffset noise * _NoiseStrength;最后用计算好的UV去采样屏幕纹理// 在URP中通过_DepthTexture和_CameraOpaqueTexture获取深度和不透明颜色 // 需要确保相机启用了Opaque Texture选项 float2 refrUV GetRefractionUV(input.screenPos, distortionOffset); float4 sceneColor SAMPLE_TEXTURE2D_X(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, refrUV); float3 refractionColor sceneColor.rgb;注意屏幕空间折射有一个固有缺陷它只能折射已经绘制在屏幕上的背景物体。如果冰块前面有另一个物体冰块折射的应该是这个物体的背面但屏幕纹理里没有这个信息。这是实时渲染中一个经典的局限对于大多数游戏场景冰块作为前景物体是可以接受的。3.3 菲涅尔反射与高光计算菲涅尔效应决定了在视角不同的地方反射和折射的强度比例。在Shader中我们常用Schlick近似公式来计算菲涅尔系数Ffloat FresnelSchlick(float cosTheta, float F0) { return F0 (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }其中cosTheta是视线方向与法线点积的绝对值saturate(dot(normalize(N), normalize(V)))F0是基础反射率对于冰这类电介质通常在0.02-0.05左右。我们将菲涅尔系数用于混合反射和折射/散射float fresnel FresnelSchlick(VdotN, 0.04); float3 reflectionColor // ... 计算环境反射如采样Cubemap和高光反射 float3 transmissionColor lerp(refractionColor, scatterColor, _ScatterStrength); // 粗略混合折射和散射 float3 finalColor lerp(transmissionColor, reflectionColor, fresnel); // 再加上直接高光 finalColor directSpecular;对于高光反射URP的Lit着色器已经提供了完善的直接光和间接光环境光计算函数如LightingPhysicallyBased。我们通常只需要调整传入的SurfaceData和InputData参数特别是金属度设为0、光滑度设高和基础颜色。4. 次表面散射SSS的模拟实现次表面散射是冰材质灵魂所在。完全物理正确的SSS计算如用扩散剖面或随机游走开销很大。在实时渲染中我们常用一些技巧来近似。4.1 厚度估算与散射强度散射光主要从薄的地方透出。因此我们需要估算模型每个点的“厚度”。一个经典技巧是使用顶点法线反向的视角深度差。在顶点着色器中将顶点沿着法线反向即指向模型内部偏移一小段距离并计算这个偏移点在屏幕空间的深度。在片元着色器中获取当前片元的实际深度。两者的差值近似代表了光线在这个点穿过模型的厚度。// 在顶点着色器中计算背面点的裁剪空间位置 Varyings vert(Attributes input) { // ... 常规计算 // 计算背面偏移点在模型空间沿法线反向移动 float3 backPosOS input.positionOS.xyz - input.normalOS * _Thickness; // _Thickness是一个可调参数 VertexPositionInputs backVertexInput GetVertexPositionInputs(backPosOS); output.backPositionCS backVertexInput.positionCS; // 传递给片元 return output; } // 在片元着色器中 float frag(Varyings input) : SV_Target { // 获取当前片元的线性深度 float frontDepth LinearEyeDepth(input.positionCS.z, _ZBufferParams); // 获取背面点的深度需要将背面裁剪坐标转换为屏幕UV并采样深度图 float2 backUV input.backPositionCS.xy / input.backPositionCS.w; float backDepth LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, backUV), _ZBufferParams); float thickness max(0, backDepth - frontDepth); // 根据厚度计算散射强度越薄的地方散射越强 float scatterIntensity exp(-thickness * _ScatterFalloff) * _ScatterStrength; return scatterIntensity; }4.2 散射颜色与方向性扭曲散射光不仅有强度变化其颜色和方向也受材质影响。我们通常用_ScatterColor来着色。此外为了模拟光线在内部散射后方向变得杂乱我们可以对用于采样背景的UV施加一个与法线相关的随机扭曲这个扭曲可以和上面用于折射的扭曲不同强度更弱、更随机。// 使用一个简单的噪声或基于法线和位置的哈希函数来生成随机偏移 float3 scatterOffset normalize(float3(input.normalWS.y, input.normalWS.z, input.normalWS.x)) * _ScatterDistortion; float2 scatterUV GetRefractionUV(input.screenPos, scatterOffset.xy); float3 scatterBaseColor SAMPLE_TEXTURE2D_X(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, scatterUV).rgb; float3 scatterFinal scatterBaseColor * _ScatterColor.rgb * scatterIntensity;将计算好的散射颜色与折射颜色进行混合。混合方式可以是简单的加法但要注意能量守恒避免过曝。更高级的做法是使用一个基于厚度的lerp在厚的地方散射占比更高。5. 整合光照、优化与参数调节现在我们已经有了反射、折射、散射三个核心组件。接下来需要将它们整合到URP的光照框架中并考虑性能优化。5.1 在URP Lit框架内整合自定义光照我们不需要重写整个光照流程。URP的UniversalFragmentPBR函数是我们的入口。关键是要正确准备SurfaceData和InputData。SurfaceData surfaceData; surfaceData.albedo _BaseColor.rgb; surfaceData.specular _SpecColor.rgb; surfaceData.metallic 0.0; // 冰是非金属 surfaceData.smoothness _Smoothness; surfaceData.normalTS float3(0,0,1); // 如果没有法线贴图 surfaceData.occlusion 1.0; surfaceData.emission 0.0; surfaceData.alpha _BaseColor.a; // 透明度 InputData inputData; InitializeInputData(input, surfaceData.normalTS, inputData); // 计算我们自定义的折射和散射颜色 float3 customTransmission CalculateIceTransmission(inputData.normalWS, inputData.viewDirectionWS, input.screenPos, input.uv); // URP的光照计算会基于surfaceData和inputData输出直接光间接光颜色 float4 color UniversalFragmentPBR(inputData, surfaceData); // 将我们自定义的透射效果混合进去。这里需要小心处理能量守恒。 // 一种方法是将PBR计算出的漫反射部分替换或混合我们的透射颜色。 // 更物理的方法是将customTransmission视为透射项在光照函数的BRDF之外进行加算。 // URP的Lit Shader有一个_TransmissionColor属性但这里我们手动混合 color.rgb color.rgb * (1.0 - saturate(_ScatterStrength)) customTransmission; // 最后应用菲涅尔增强边缘反射 float fresnel FresnelSchlick(saturate(dot(inputData.normalWS, inputData.viewDirectionWS)), 0.04); color.rgb lerp(color.rgb, reflectionColor, fresnel * _FresnelStrength); return color;5.2 性能优化关键点冰Shader计算密集优化至关重要纹理采样优化噪声纹理尽量使用小尺寸如64x64并确保压缩格式合适。屏幕纹理_CameraOpaqueTexture采样很昂贵应尽量减少采样次数。可以考虑将折射和散射的UV扭曲计算合并只采样一次屏幕纹理然后进行轻微的颜色处理来区分两者。计算精度在移动平台将部分计算从片元着色器移到顶点着色器如部分向量变换并在片元着色器中使用half精度变量。条件判断避免在片元着色器中使用if语句。可以用saturate、step或lerp函数来替代。深度纹理精度厚度计算依赖深度图。在远距离或陡峭角度下深度差值可能不准确导致散射效果突兀。可以添加一个平滑过渡或设置最大/最小厚度限制。LOD细节层次为Shader编写LOD变体。当物体离相机很远时使用简化版本例如只保留基本的折射和菲涅尔去掉复杂的散射和噪声扰动。5.3 材质参数调节心得参数调节是让材质“活过来”的最后一步。以下是我的经验_IndexOfRefraction (IOR): 1.31是冰的物理值。稍微调高如1.33接近水会让扭曲感更强调低则更像某些塑料。_RefractionDistortion: 这是最影响“扭曲感”的参数。从0.01开始微调过大会导致失真严重看起来不真实。_BaseColor和_ScatterColor:_BaseColor控制整体色调通常设为偏蓝的浅色透明度在0.3-0.7之间。_ScatterColor通常比_BaseColor饱和度更低、明度更高偏向乳白色用于模拟内部散射光。_Smoothness: 冰非常光滑建议保持在0.9以上才能获得清晰的高光边缘。_NoiseStrength和_NoiseScale: 噪声用于打破折射和散射的均匀性。强度Strength宜小不宜大0.05-0.15尺度Scale根据模型大小调整模型越大尺度可以相应增大。调试技巧在Shader中创建临时输出通道将厚度、菲涅尔系数、散射强度等中间变量直接输出为颜色可以直观地看到每个部分的影响范围极大提高调节效率。6. 常见问题、排查技巧与进阶方向即使按照步骤实现你也可能会遇到一些棘手的问题。这里记录了几个我踩过的坑和解决方法。6.1 常见问题速查表问题现象可能原因排查与解决思路折射效果完全看不到1. 相机未启用Opaque Texture。2. 屏幕UV计算错误采样到了屏幕外。3. 折射扭曲强度_RefractionDistortion设为0。1. 检查URP Asset设置和相机设置确保Opaque Texture选项打开。2. 在Shader中输出distortionOffset或最终的refrUV作为颜色检查其值是否在合理范围0-1。3. 逐步调高扭曲强度参数。折射边缘有硬边或闪烁1. 屏幕UV在边缘未钳制采样到纹理边界外。2. 深度纹理精度不足在物体边缘深度值突变。1. 在GetRefractionUV函数中对UV进行clamp或saturate。2. 尝试对深度差值或扭曲偏移进行平滑处理如使用smoothstep。可以考虑在物体边缘淡出折射效果。冰材质看起来像实心塑料不透1. 透明度_BaseColor.a设置过高。2. 散射强度_ScatterStrength太弱或散射颜色太深。3. 缺少厚度变化散射效果均匀。1. 降低基础透明度至0.5以下。2. 增强散射强度并将散射颜色调亮、调白。3. 检查厚度计算是否正确确保模型有体积感厚的地方散射弱薄的地方散射强。在移动设备上帧率骤降1. 片元着色器计算过于复杂。2. 全屏分辨率下多次采样屏幕纹理和深度纹理。1. 使用Shader LOD为远距离物体使用简化Shader。2. 考虑将屏幕纹理采样降频如每2x2像素采样一次周围像素复用或使用更廉价的近似散射模型。与后处理效果如Bloom冲突折射采样的是不透明纹理可能不包含后处理效果。顺序问题。确保折射计算在后处理之前进行比较困难。一个妥协方案是让冰材质本身也接受后处理虽然折射部分会“二次处理”但视觉上通常可以接受。6.2 进阶优化与效果提升方向当你实现了基础版本后可以考虑以下方向让效果更上一层楼基于距离的细节淡化根据像素到相机的距离线性淡化噪声扰动和复杂散射计算用更简单的颜色混合代替节省远处像素的计算量。双面渲染默认的渲染剔除了背面。对于透明冰需要看到内部。可以在SubShader中设置Cull Off并小心处理双面光照避免能量计算错误。环境光遮蔽AO集成冰内部的裂缝和凹陷处应该更暗。可以将一张烘焙或屏幕空间的AO图乘到散射或最终颜色上增加立体感。动态裂纹效果通过一张随时间变化的裂纹法线贴图或高度图动态影响折射方向和散射强度可以模拟冰裂开或融化的效果。与全局光照GI结合让冰的散射颜色能受到周围场景颜色的影响即颜色渗出这需要更复杂的全局光照探针或光照贴图支持。实现一个逼真的冰纹理折射Shader是一个从理论到实践、不断调试和权衡的过程。它没有唯一的“正确”答案最终效果取决于你对视觉风格的追求和项目性能的预算。最关键的是理解每个参数背后的物理意义并学会用视觉调试工具来分析和解决问题。这个过程中积累的经验对你未来处理其他复杂材质如玉石、皮肤、蜡等将大有裨益。