尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity Shader实现瓶内液体效果:从深度约束到折射渲染全解析

Unity Shader实现瓶内液体效果:从深度约束到折射渲染全解析 1. 从“一个瓶子”到“一杯水”Shader液体效果的视觉魔法在游戏和实时渲染的世界里有些效果看似简单却最能考验一个图形程序员的功底。比如一个装着液体的瓶子。你可能会想这不就是个半透明的圆柱体加上一个平面吗但当你真正动手去实现时会发现事情远没有那么简单液体表面如何自然地弯曲液体与瓶壁接触的边缘如何处理液体内部的折射和焦散效果怎么模拟这些细节恰恰是让一个场景从“还行”跃升到“惊艳”的关键。最近我在为一个风格化项目实现瓶内液体效果时把市面上常见的几种Shader方案都折腾了一遍从最基础的顶点偏移到复杂的屏幕空间折射踩了不少坑也总结了一套比较务实、效果也还不错的实现方案。今天我就把这个过程连同完整的Shader源码和思路毫无保留地分享出来。无论你是刚接触Shader的新手还是想优化现有效果的老手相信都能从中找到一些可以直接“抄作业”的点。这个效果的核心我们称之为“瓶内液体Shader”。它要解决的核心视觉问题是让一段3D模型液体看起来像是被约束在另一个3D模型瓶子内部并且拥有液体特有的光学特性。我们会用到Unity的ShaderLab和HLSL来编写但其中的思路是通用的可以迁移到其他引擎。2. 液体效果的本质约束、表面与光线的三重奏在动手写代码之前我们必须先想清楚一个逼真的瓶内液体在视觉上由哪几个关键部分构成。如果分解错了后面怎么写都是徒劳。2.1 几何约束液体如何“乖乖”待在瓶子里这是第一个也是最基础的问题。我们通常会有两个模型瓶子的模型和液体的模型。最天真的做法是让美术做一个和瓶子内部形状完全一致的液体模型。但这带来两个问题一是工作量巨大每个瓶子都要单独做二是当瓶子动画比如倾斜时液体模型不会自动适应瓶子的新形状。因此Shader方案的第一步就是在Shader中动态地对液体模型进行几何约束。核心思路是利用瓶子的深度信息。具体来说在片元着色器Fragment Shader中我们可以获取当前像素在世界空间或视图空间中的位置然后与瓶子的内表面深度进行比较。如果当前液体像素的位置超出了瓶子的内表面我们就将其裁剪Clip掉或者将其位置“推回”到瓶子内表面。这里的一个关键技巧是使用深度纹理Depth Texture。我们可以将瓶子单独渲染到一个深度缓冲区然后在液体Shader中采样这个深度图。通过对比当前片元的深度值与瓶子内表面的深度值来判断该片元是否可见。// 示例简单的深度测试约束概念代码 float sceneDepth LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenUV)); float surfaceDepth LinearEyeDepth(fragPos.z); // fragPos是当前液体片元在视图空间下的深度 if (surfaceDepth sceneDepth _DepthBias) // 如果液体深度比瓶子深度大更远说明在瓶子后面 { clip(-1); // 裁剪掉这个片元 }当然实际情况会更复杂因为瓶子本身是透明的我们需要区分瓶子的前表面和后表面。通常我们会要求瓶子的模型是封闭的并且使用一个特定的渲染队列和Stencil Buffer来标记瓶子的内部体积。2.2 液体表面不仅仅是平面约束好几何边界后接下来是液体的表面。静止的液体表面是一个平面但这样看起来非常假。真实的液体表面尤其是在接近瓶壁的地方会因为表面张力而形成一个弯月面Meniscus。此外当瓶子晃动时表面应该有波浪动画。表面形状的模拟可以通过顶点着色器对液体模型顶点的Y轴或法线方向进行正弦波扰动来实现。对于弯月面效果一个取巧的办法是使用一张高度图Height Map或者根据顶点到瓶壁中心的距离来对表面顶点进行偏移。距离瓶壁越近顶点向上偏移的程度越大。// 示例简单的表面正弦波动画在顶点着色器中 v.vertex.y sin(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequency) * _WaveAmplitude;表面法线的计算至关重要它直接影响高光和折射。如果我们对顶点进行了偏移就必须重新计算法线或者使用法线贴图Normal Map来模拟表面的细微波澜。一个动态的、轻微扰动的法线能让液体表面立刻“活”起来。2.3 光学特性折射、反射与颜色这是让液体看起来像“液体”而非“果冻”的灵魂所在。液体的光学特性主要包括折射Refraction光线穿过液体时会发生弯曲。这是最显著的特征。在Shader中我们通常采用屏幕空间折射Screen Space Refraction。原理是扭曲采样背景颜色所用的UV坐标。我们根据液体的表面法线和一个折射率参数计算一个偏移向量然后用这个偏移向量去扰动屏幕UV坐标再去采样摄像机的场景颜色纹理GrabPass或Opaque Texture。// 示例简化的屏幕空间折射计算 float2 offset surfaceNormal.xy * _RefractionStrength / fragPos.w; float2 refractedUV screenUV offset; float4 backgroundColor tex2D(_GrabTexture, refractedUV);反射Reflection液体表面会反射环境。对于风格化或性能敏感的场景我们可以用立方体贴图Cubemap来做环境反射。对于追求真实感的场景可能会结合屏幕空间反射SSR。反射的强度通常由菲涅尔效应Fresnel Effect控制视线与表面法线夹角越大即掠射角反射越强。// 示例菲涅尔效应计算 float fresnel pow(1.0 - saturate(dot(viewDir, surfaceNormal)), _FresnelPower); float3 reflection texCUBE(_EnvironmentCube, reflect(-viewDir, surfaceNormal)).rgb; float3 finalColor lerp(refractedColor, reflection, fresnel * _ReflectionStrength);颜色与吸收Color Absorption液体不是无色的。光线在液体中传播得越深某些波长的光被吸收得越多。我们可以模拟这个效果根据液体内部的深度从表面到当前片元的距离让颜色向某个色调偏移或变暗。焦散Caustics这是高端效果模拟光线透过液体后在瓶底或周围表面形成的光斑。可以通过投影一张动态的焦散贴图来实现计算量较大在移动端需谨慎使用。把这三大要素——几何约束、表面形态、光学特性——有机地组合起来一个基本的瓶内液体Shader框架就清晰了。3. 实战构建一个完整的Unity Shader Graph实现对于不习惯直接写HLSL代码的开发者Unity的Shader Graph是一个强大的可视化工具。我们同样可以用它来搭建这个效果。下面我拆解一下在Shader Graph中的关键节点设置。3.1 建立深度测试约束系统在Shader Graph中我们没有直接的Clip节点但可以通过“Depth”节点和比较操作来实现。获取场景深度使用“Scene Depth”节点。将屏幕位置Screen Position连接给它它会输出对应像素处的场景深度值。获取表面深度使用“View Direction”和“Position”节点计算当前片元的视图空间深度或者直接使用“Fragment Stage”的“Depth”输出如果图元是透明的可能需要转换。深度比较与遮罩用一个“Subtract”节点用“表面深度”减去“场景深度”。理论上如果液体在瓶子内部这个值应该为负因为液体更靠近相机。我们用一个“Step”节点当差值大于一个很小的阈值比如0.001时输出0否则输出1。这个输出值就作为一个遮罩Mask。应用遮罩将计算得到的颜色与这个遮罩值相乘。如果片元在瓶子外部遮罩为0最终颜色就是完全透明的黑色。注意这种方法在透明渲染顺序Transparent Queue下可能不可靠因为透明物体不写入深度。一个更健壮的方法是让瓶子使用一个特殊的Shader将它的内部体积信息写入到一张自定义的纹理或Stencil Buffer中液体Shader再读取这个信息。这超出了基础Shader Graph的范围通常需要配合脚本和额外的摄像机。3.2 创建动态液体表面顶点偏移在Shader Graph中顶点偏移需要在“Vertex Stage”完成。添加一个“Vertex Position”节点。生成波浪使用“Time”节点结合“Sine”节点生成波动函数。用模型空间的位置如Object Position的XZ分量作为输入乘以一个频率参数加上时间再乘以振幅参数得到偏移量。应用偏移将计算出的偏移量沿着模型空间的Y轴或顶点法线方向添加到“Vertex Position”上然后输出到主节点的“Vertex Position”插槽。重新计算法线顶点偏移后默认的法线会失效。我们需要重新计算。一个近似方法是使用“DDX”和“DDY”节点对偏移后的世界空间位置求导然后叉积得到新的法线。更简单的方法是准备一张法线贴图来模拟表面细节而将顶点偏移造成的大尺度法线变化忽略这对于小幅波动是可以接受的。3.3 实现折射与反射折射使用“Scene Color”节点获取背景色。使用“Normal Vector”节点获取表面法线。使用“Refraction”节点输入法线和折射率IOR它会输出一个折射向量。将屏幕空间坐标Screen Position加上这个折射向量需要缩放作为新的UV去采样“Scene Color”。这里Shader Graph没有直接的GrabPass但“Scene Color”节点通常包含了透明物体背后的不透明场景颜色可以近似模拟。菲涅尔反射使用“Fresnel Effect”节点。输入表面法线和视图方向它会输出一个边缘亮、中间暗的遮罩。使用“Cubemap”节点采样环境贴图。反射向量可以通过“Reflection Vector”节点获得输入视图方向和法线。用菲涅尔因子作为混合系数将折射颜色和反射颜色进行“Lerp”混合。将这些子系统在Shader Graph中连接起来你就得到了一个可视化的、可实时调整的瓶内液体Shader。它的优势是迭代快参数调整直观非常适合原型设计和风格化效果。4. 核心源码拆解一个手写的Unity Shader可视化工具虽好但想要极致控制和理解每一行代码在做什么手写Shader是无法绕开的。下面是我最终使用的一个相对完整的Surface Shader变体它平衡了效果和性能。我会逐段解释关键部分。Shader Custom/BottleLiquid { Properties { _Color (液体颜色, Color) (0.5, 0.8, 1.0, 0.8) _MainTex (表面纹理, 2D) white {} _BumpMap (法线贴图, 2D) bump {} _BumpScale (法线强度, Range(0,2)) 1.0 _IOR (折射率, Range(1.0, 2.0)) 1.33 // 水的折射率约1.33 _Depth (液体深度系数, Range(0, 5)) 1.0 _FresnelPower (菲涅尔强度, Range(0.5, 10)) 5.0 _WaveSpeed (波浪速度, Float) 1.0 _WaveFrequency (波浪频率, Float) 10.0 _WaveAmplitude (波浪幅度, Range(0, 0.1)) 0.02 _BottleThickness (瓶壁厚度容差, Range(0, 0.1)) 0.01 } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } LOD 300 // 抓取屏幕内容用于折射 GrabPass { _GrabTexture } CGPROGRAM #pragma surface surf Standard alpha:fade vertex:vert #pragma target 3.0 #include UnityCG.cginc sampler2D _MainTex, _BumpMap, _GrabTexture; float4 _GrabTexture_TexelSize; fixed4 _Color; float _BumpScale, _IOR, _Depth, _FresnelPower; float _WaveSpeed, _WaveFrequency, _WaveAmplitude; float _BottleThickness; struct Input { float2 uv_MainTex; float2 uv_BumpMap; float3 viewDir; float4 screenPos; // 用于获取屏幕坐标 float3 worldPos; // 世界坐标用于深度计算 INTERNAL_DATA }; // 顶点着色器实现表面波浪动画 void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 简单的正弦波沿X轴方向传播 float wave sin(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequency) * _WaveAmplitude; v.vertex.y wave; // 注意这里为了简化没有重新计算切线空间法线。更严谨的做法需要在这里计算新的法线和切线。 } // 表面着色器主函数 void surf (Input IN, inout SurfaceOutputStandard o) { // 1. 基础颜色和法线 fixed4 texColor tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Normal UnpackScaleNormal(tex2D(_BumpMap, IN.uv_BumpMap), _BumpScale); // 2. 屏幕空间折射 float2 screenUV IN.screenPos.xy / IN.screenPos.w; // 标准化屏幕坐标 #if UNITY_UV_STARTS_AT_TOP screenUV.y 1 - screenUV.y; // 处理DX/OpenGL平台差异 #endif // 根据法线和折射率计算偏移 float3 refractVec refract(normalize(IN.viewDir), o.Normal, 1.0 / _IOR); // 注意是1/IOR float2 offset refractVec.xy * _GrabTexture_TexelSize.xy * _Depth * 100; // 缩放偏移量 float2 refractedUV screenUV offset; fixed4 grabColor tex2D(_GrabTexture, refractedUV); // 3. 菲涅尔反射 float fresnel pow(1.0 - saturate(dot(normalize(IN.viewDir), o.Normal)), _FresnelPower); // 4. 模拟深度吸收简化版根据视角深度变暗 // 这里用一个假定的液体深度实际项目中可以根据顶点到液体表面的距离来计算 float depthFactor 1.0 - saturate(length(offset) * 2); // 偏移越大模拟深度越深 fixed4 liquidColor texColor; liquidColor.rgb * depthFactor; // 5. 最终混合折射底色 菲涅尔高光 液体本色 o.Albedo grabColor.rgb * (1 - fresnel) liquidColor.rgb * 0.3; // 混合 o.Alpha _Color.a * (1 - fresnel * 0.5); // 边缘更透明 o.Metallic 0.0; o.Smoothness 0.9; // 液体表面通常很光滑 // 6. 简易瓶壁约束性能开销大仅作演示。生产环境应用2.1节提到的深度图方法 // 此处省略复杂的场景深度比较代码假设瓶子模型已正确设置渲染队列和裁剪面。 } ENDCG } FallBack Transparent/Diffuse }关键点解析与避坑指南GrabPass的性能GrabPass在移动端是性能杀手因为它意味着额外的全屏纹理拷贝。对于移动平台应尽量避免或者使用GrabPass { }不带名称的方式让同一帧中所有使用它的Shader共享同一张抓取纹理。更好的方案是使用Unity的Opaque Texture在URP中为_CameraOpaqueTexture。折射方向refract函数的第三个参数是“入射介质的折射率 / 出射介质的折射率”。通常我们从空气IOR≈1进入液体IOR≈1.33所以是1.0 / _IOR。如果弄反了折射方向会完全错误。法线重计算我在vert函数中移动了顶点但没有更新法线。这在波浪幅度很小时问题不大但如果幅度大会导致光照错误。一个补救措施是在surf函数中使用ddx和ddy根据世界坐标重新计算法线但这会增加开销。最佳实践是在顶点着色器中同步计算新的法线和切线。深度约束的缺失这个示例Shader缺少了最关键的瓶壁深度约束逻辑第6点已说明。在实际项目中你必须实现2.1节描述的深度测试或Stencil方案否则液体会“溢出”瓶子。这里省略是为了代码清晰但这是生产级实现不可省略的一环。Alpha混合与渲染队列我们使用了Tags { QueueTransparent }和alpha:fade。这意味着这个Shader会进行Alpha混合并且需要在所有不透明物体渲染之后渲染。你必须确保瓶子的渲染队列在液体之前例如瓶子是QueueGeometry并且瓶子Shader要正确写入深度或Stencil液体才能进行深度测试。5. 进阶优化与风格化拓展基础效果实现后我们可以根据项目需求进行优化和风格化。5.1 性能优化方案用Opaque Texture替代GrabPass在URP或自定义渲染管线中直接采样_CameraOpaqueTexture。这避免了全屏抓取的开销。简化折射计算对于低端设备可以完全去掉屏幕空间折射只用立方体贴图反射和颜色来模拟。或者只对液体边缘部分通过菲涅尔判断进行折射计算中心部分直接使用液体颜色。降低深度约束精度如果瓶子形状规则可以不用每像素深度测试而是在顶点着色器中用简单的数学公式如球体、圆柱体SDF将顶点约束在瓶内。这能极大提升性能。烘焙静态效果如果瓶子和液体都是静止的可以考虑将带折射的背景和瓶子的阴影一起烘焙到一张贴图上运行时只需渲染一个简单的透明面片。5.2 风格化方向卡通化液体去掉复杂的折射和菲涅尔用硬边的高光和纯色填充。关键在于用step或smoothstep函数处理光影分界并给液体边缘描一道高光边。程序化纹理不用法线贴图而是在Shader中程序化生成噪声用来扰动表面法线或颜色创造出墨水、岩浆、魔法药水等特殊液体的流动感。顶点色驱动在液体模型的顶点色中存储信息比如R通道代表深度G通道代表流速。在Shader中读取这些信息可以实现不同区域颜色不同、表面波动强度不同等复杂效果。实现一个令人信服的瓶内液体效果是一个从理解物理现象开始到运用图形学工具进行简化模拟最后在性能和效果之间找到平衡点的过程。它没有唯一的正确答案我的这套方案也只是无数可能中的一种。最重要的是理解每个步骤背后的意图深度测试是为了约束边界顶点动画是为了模拟表面张力折射和菲涅尔是为了模拟光线交互。当你理解了这些“为什么”你就可以自由地调整、删减或增强其中的任何部分来适应你项目的独特风格和性能要求。希望这份详细的拆解和源码能成为你探索这个有趣渲染领域的一块扎实的跳板。
返回列表