
1. 项目概述从“瓶子”到“液体”的视觉魔法在游戏开发、影视特效甚至一些交互式应用里我们常常需要模拟容器内液体的视觉效果。一个静态的、贴图粗糙的液体面片和一套能随着视角、容器形状、液体状态动态变化的Shader效果带来的沉浸感是天差地别的。今天要聊的就是如何用Shader来实现一个逼真的瓶内液体效果并且我会附上完整的源码和思路拆解。这不仅仅是贴一张带透明度的纹理那么简单它涉及到对光线在液体表面折射、反射的模拟对液体与容器壁接触边缘即弯月面的处理以及对液体内部颜色、浑浊度甚至气泡的刻画。想象一下你手中有一瓶橙汁、一瓶红酒或者一瓶魔法药水。当你看它时液面会反射环境光光线穿过液体时会发生折射让你看到扭曲的瓶底或背后的物体液体与玻璃瓶接触的边缘会有一圈因为表面张力而形成的弧形曲面摇晃瓶子时液体还会晃动甚至产生泡沫。这些细节正是我们要用Shader编程去“欺骗”玩家眼睛的地方。这个效果的核心价值在于它用相对可控的GPU计算成本替代了极其昂贵的流体物理模拟在视觉上达到了“以假乱真”的目的非常适合移动端或对性能有要求的项目。无论你是Unity的Shader Graph用户还是习惯手写HLSL/GLSL代码的开发者理解这套效果的实现原理都是通用的。接下来我会从最基础的思路开始一步步拆解如何构建这个效果并分享我在实现过程中踩过的坑和优化技巧。2. 核心效果拆解与实现思路要实现一个 convincing有说服力的瓶内液体效果我们不能把它当成一个简单的平面来处理。我们需要将它分解成几个可以独立实现、再组合起来的视觉层。这样不仅思路清晰也便于后续调整和优化。2.1 视觉层分解液体到底由什么构成一个完整的瓶内液体视觉效果可以分解为以下四个核心层液体表面液面这是最上层决定了液体的基本轮廓和与空气接触的界面。它需要处理镜面反射高光和菲涅尔效应视线与表面夹角越小反射越强。液体体积主体这是液体的“肉”决定了液体的颜色、透明度和光线在其中的传播方式折射、吸收。这是实现“透过液体看东西会扭曲”的关键。液体边缘弯月面液体与容器壁接触的部分由于表面张力和浸润作用会形成一个内凹或外凸的弧形曲面。这个细节是区分“一滩水”和“装在瓶子里的水”的关键。动态与细节气泡、浑浊高级效果包括悬浮的气泡、液体内部的杂质或浑浊感如牛奶、果汁以及液体晃动时表面的波动。我们的Shader需要同时处理这四层信息。在实现策略上通常有两种主流方式表面着色器Surface Shader思路在Unity中可以编写一个表面着色器在surf函数中综合计算反射、折射、颜色等所有光照信息。这种方式与Unity的光照管线集成较好但自定义程度和性能控制稍弱。片元着色器Fragment Shader主导思路在顶点/片元着色器或Shader Graph中我们完全掌控每一个像素的计算。我们通过法线贴图或顶点动画来模拟液面波动在片元着色器中手动计算折射、反射向量并采样场景纹理或立方体贴图来模拟光学效果。这种方式更灵活也是本次讲解的重点。2.2 关键技术选型为什么是这些方案在动手写代码之前有几个关键的技术决策点需要明确这直接决定了效果的逼真度和性能。1. 折射的实现屏幕空间折射 vs 立方体贴图折射屏幕空间折射使用GrabPass或后处理方式抓取当前渲染好的场景画面作为纹理然后在液体区域根据法线和视线方向计算偏移量去扭曲采样这张“屏幕截图”。它的优点是能准确折射场景中实际存在的物体动态物体也能被折射效果最真实。立方体贴图折射预先烘焙或使用一个天空盒立方体贴图Cubemap作为环境。计算折射方向后直接采样这张立方体贴图。它的优点是性能极佳一次采样即可但缺点是折射的内容是静态环境无法反映场景中动态的物体。实操心得对于瓶子这种小范围物体且场景背景相对固定如室内场景的情况使用立方体贴图折射是性价比最高的选择移动端友好。如果瓶子是主角且需要折射出角色、敌人等动态物体则必须考虑屏幕空间折射但要注意GrabPass在移动端的性能开销和可能的渲染顺序问题。2. 液面波动的模拟顶点动画 vs 法线贴图顶点动画在顶点着色器中根据时间和一个噪声函数直接移动液体表面网格顶点的位置。这种方式产生的波动是真实的几何形变但需要模型有足够多的顶点密度即面数够高否则波动会显得很生硬。法线贴图保持液体表面网格平整通过一张或一套动态采样的法线贴图来改变每个像素的法线方向。光照和折射计算基于这个“虚假”的法线进行从而在视觉上模拟波动。这是最常用、性能最好的方法可以用一张噪声图通过平移和叠加来模拟复杂的动态波纹。我的选择对于瓶内液体这种通常面积不大、且波动细微的效果法线贴图方案是首选。我们可以用一张简单的噪声图通过Shader中时间变量控制其UV偏移并可能用两张不同频率和速度的噪声图叠加来打破重复感创造出更自然的涟漪。3. 弯月面的模拟几何 vs 着色几何方式在建模时就在瓶子内壁与液体接触的部分创建一个环状的、带有弧形截面的网格。这种方式最准确但不够灵活液体高度一变模型就得改。着色方式在Shader中通过计算像素位置到瓶子中心轴的距离与液体高度的关系来模拟一个圆环形的、颜色/透明度渐变区域。我们可以用一个平滑函数如smoothstep在接触边缘生成一个深色的、半透明的环。我的选择着色方式提供了最大的灵活性。我们可以在Shader中用参数控制弯月面的宽度、颜色和强度甚至可以让它随着液体晃动而轻微变形这是纯几何模型难以做到的。基于以上分析我们的实现蓝图已经清晰一个以片元着色器为核心的Shader使用法线贴图模拟液面波动采用立方体贴图实现折射并通过距离计算在着色阶段生成弯月面效果。3. 核心Shader模块实现详解有了清晰的思路我们就可以开始搭建Shader的各个模块了。这里我将使用Unity的ShaderLab和HLSL来编写一个Unlit类型的Shader以便清晰地展示每一个计算步骤。你也可以很容易地将这些节点逻辑移植到Shader Graph中。3.1 基础结构与属性定义首先我们定义Shader所需的各种属性Properties这些可以在材质面板中调节。Shader Custom/BottleLiquid { Properties { // 液体基础颜色与外观 _LiquidColor (液体颜色, Color) (0.3, 0.6, 1.0, 0.8) _Density (浑浊度/密度, Range(0, 5)) 1.0 // 控制颜色强度和折射强度 _Smoothness (光滑度, Range(0, 1)) 0.9 // 用于反射计算 // 液面波动 _NormalMap (法线贴图, 2D) bump {} _NormalStrength (法线强度, Range(0, 2)) 0.3 _WaveSpeed (波动速度, Float) 1.0 _WaveTiling (波动平铺, Float) 1.0 // 弯月面 (Meniscus) _MeniscusWidth (弯月面宽度, Range(0.01, 0.5)) 0.05 _MeniscusColor (弯月面颜色, Color) (0.1, 0.1, 0.2, 1) _MeniscusStrength (弯月面强度, Range(0, 2)) 1.0 // 折射与反射 _RefractionStrength (折射强度, Range(0, 1)) 0.1 _ReflectionStrength (反射强度, Range(0, 1)) 0.5 _FresnelPower (菲涅尔系数, Range(0, 10)) 5.0 _EnvCubemap (环境立方体贴图, Cube) _Skybox {} } SubShader { Tags { RenderTypeTransparent QueueTransparent } LOD 100 Pass { Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc #include Lighting.cginc // 将Properties中的变量声明到CG代码中 sampler2D _NormalMap; float4 _NormalMap_ST; // 包含Tiling和Offset samplerCUBE _EnvCubemap; float4 _LiquidColor; float _Density, _Smoothness; float _NormalStrength, _WaveSpeed, _WaveTiling; float _MeniscusWidth, _MeniscusStrength; float4 _MeniscusColor; float _RefractionStrength, _ReflectionStrength, _FresnelPower; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; float3 worldNormal : TEXCOORD2; float3 worldTangent : TEXCOORD3; float3 worldBitangent : TEXCOORD4; float3 worldViewDir : TEXCOORD5; }; // ... 后续代码这部分定义了所有可调参数。_NormalMap_ST是Unity内置宏方便我们处理法线贴图的缩放和偏移。v2f结构体传递了从顶点着色器到片元着色器所需的所有信息包括世界空间下的位置、法线、切线、副切线和视线方向这些都是后续计算光照和折射的基础。3.2 顶点着色器构建坐标系顶点着色器的任务是将模型顶点变换到裁剪空间并计算并传递世界空间的各种向量。v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _NormalMap); // 应用纹理的Tiling和Offset // 计算世界空间位置和法线 o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldTangent UnityObjectToWorldDir(v.tangent.xyz); o.worldBitangent cross(o.worldNormal, o.worldTangent) * v.tangent.w; // 注意副切线的方向 // 计算世界空间视线方向从顶点指向相机 o.worldViewDir UnityWorldSpaceViewDir(o.worldPos); return o; }这里的关键是构建了世界空间的TBN矩阵。TBNTangent, Bitangent, Normal矩阵是一个3x3的矩阵它的每一行分别是世界空间下的切线、副切线、法线向量。这个矩阵的作用是将法线贴图中存储的、在切线空间下的法线向量变换到世界空间以便与世界空间下的光线、视线等向量进行正确的点乘计算。3.3 片元着色器核心液面波动与法线计算这是最核心的部分。我们首先通过法线贴图模拟动态液面。float4 frag (v2f i) : SV_Target { // 1. 动态UV计算模拟液面流动 float2 waveUV i.uv * _WaveTiling; float2 flowVector float2(0.1, 0.2) * _WaveSpeed; // 一个简单的流动方向向量 waveUV flowVector * _Time.y; // 随时间偏移UV // 2. 采样法线贴图并解包到[-1, 1]范围 float3 normalTangent UnpackNormal(tex2D(_NormalMap, waveUV)); normalTangent.xy * _NormalStrength; // 控制波动强度 normalTangent normalize(normalTangent); // 3. 将切线空间法线变换到世界空间 float3x3 TBN float3x3( normalize(i.worldTangent), normalize(i.worldBitangent), normalize(i.worldNormal) ); float3 worldNormal mul(TBN, normalTangent); worldNormal normalize(worldNormal); // 归一化世界空间视线向量 float3 worldViewDir normalize(i.worldViewDir); // 计算世界空间反射向量 float3 worldRefl reflect(-worldViewDir, worldNormal); // 计算世界空间折射向量 (使用简单的折射率近似例如1.33对应水) float refractionRatio 1.0 / 1.33; // 空气进入液体的折射率比值 float3 worldRefr refract(-worldViewDir, worldNormal, refractionRatio);这里有几个要点_Time.y是Unity内置的时间变量以秒为单位通过它驱动UV偏移产生动画效果。UnpackNormal是UnityCG.cginc中提供的函数用于将纹理中压缩存储的法线向量通常是在[0,1]范围解包到[-1, 1]范围。构建TBN矩阵时务必对切线、副切线、法线进行归一化normalize确保矩阵是正交的否则变换会出错。reflect和refract是HLSL内置函数分别计算反射和折射方向。refract函数的第三个参数是入射介质折射率与折射介质折射率的比值。这里我们假设从空气~1.0进入水~1.33所以比值为 1.0/1.33。3.4 光学效果合成折射、反射与菲涅尔有了世界空间的法线、反射和折射向量我们就可以合成最终的光学效果了。// 4. 采样环境贴图模拟折射与反射 float4 refrColor texCUBE(_EnvCubemap, worldRefr); float4 reflColor texCUBE(_EnvCubemap, worldRefl); // 5. 计算菲涅尔效应 (Fresnel) // 视线与法线夹角越大掠射角反射越强夹角越小垂直看反射越弱折射越强。 float fresnel pow(1.0 - saturate(dot(worldViewDir, worldNormal)), _FresnelPower); // 将菲涅尔因子从[0,1]映射到更实用的范围例如反射强度占比 float reflectionRatio saturate(fresnel * _ReflectionStrength); // 6. 混合折射与反射颜色 float4 envColor lerp(refrColor, reflColor, reflectionRatio); // 应用折射强度控制主要影响折射部分 envColor lerp(float4(1,1,1,1), envColor, _RefractionStrength);菲涅尔效应是让液体看起来真实的关键。我们使用经典的Schlick近似公式的简化版pow(1.0 - NdotV, power)。_FresnelPower控制边缘反射的锐利程度值越大只有非常边缘的地方才有强反射。然后我们用这个菲涅尔因子作为权重来混合折射和反射的环境光颜色。3.5 液体颜色、浑浊度与弯月面合成最后我们将环境光与液体本身的颜色、浑浊度结合并加上弯月面效果。// 7. 应用液体基础颜色与密度浑浊度 // 密度越高液体自身颜色越浓环境光影响越弱 float4 liquidColor _LiquidColor; liquidColor.a * _Density; // 浑浊度也影响透明度 // 将环境光与液体颜色混合。密度越高液体色越主导。 float4 finalColor lerp(envColor * liquidColor, liquidColor, _Density * 0.2); finalColor.a liquidColor.a; // 最终透明度由液体颜色决定 // 8. 弯月面效果模拟 // 假设液体模型UV的V方向0到1代表从瓶底到瓶口。 // 弯月面出现在接近液体顶部UV.y接近1的边缘区域。 // 计算到中心线的水平距离这里简化处理假设模型中心在x0.5 float distanceToCenter abs(i.uv.x - 0.5) * 2.0; // 映射到[0,1]边缘为1 // 计算弯月面区域在顶部附近且靠近边缘 float meniscusMask smoothstep(1.0 - _MeniscusWidth, 1.0, distanceToCenter); meniscusMask * smoothstep(0.95, 1.0, i.uv.y); // 只作用于顶部区域 // 混合弯月面颜色 float4 meniscusColor _MeniscusColor; meniscusColor.a * meniscusMask * _MeniscusStrength; finalColor lerp(finalColor, meniscusColor, meniscusColor.a); return finalColor; }弯月面的模拟是着色技巧。我们通过smoothstep函数创建平滑的渐变区域。distanceToCenter计算像素在水平方向上偏离中心的程度i.uv.y判断是否在液体顶部。两者结合就在顶部边缘生成了一个环状的遮罩。smoothstep比简单的step函数效果更柔和自然。注意事项这里的弯月面计算是基于一个假设液体网格的UV是规则投影的且瓶身是竖直的圆柱体。对于复杂形状的瓶子可能需要更复杂的计算比如使用模型顶点在世界空间的高度和到中心轴的距离。4. 在Unity中的实际应用与优化写完Shader代码只是第一步如何在Unity中正确设置并达到最佳效果同样有很多门道。4.1 材质与模型准备模型准备你需要两个模型。一个是瓶子外壳玻璃材质设置为透明的标准着色器或自定义玻璃Shader。另一个是液体模型它应该紧密贴合瓶子内部形状但略小于瓶子内壁以避免Z-fighting深度冲突。液体模型的顶部需要一个独立的平面或曲面作为液面。创建材质将写好的Shader赋给一个新的材质球然后拖给液体模型。环境立方体贴图这是关键。你可以使用Unity场景的Skybox或者自己烘焙一个Cubemap。对于室内场景可以在瓶子大概的位置放一个Reflection Probe反射探头让它烘焙出周围环境的立方体贴图然后将该Cubemap拖到材质的_EnvCubemap属性上。这是效果逼真的核心。法线贴图找一张水波纹或噪声法线贴图。在材质面板调整_WaveSpeed和_WaveTiling直到波动看起来自然。_NormalStrength不宜过大否则折射会过于扭曲失真。4.2 参数调节心法参数调节是个艺术活这里有一些经验值和建议参数作用推荐范围/建议_LiquidColor液体基础色啤酒淡黄低透明度红酒深红中高透明度苏打水无色高透明度_Density浑浊度/密度清水0.2~0.5果汁1.0~2.0牛奶3.0_Smoothness光滑度通常保持较高0.8~1.0影响反射清晰度_NormalStrength法线强度0.1~0.5过大会导致折射扭曲过度像哈哈镜_WaveSpeed波动速度0.1~1.0缓慢流动更真实_RefractionStrength折射强度0.05~0.2控制环境扭曲程度_ReflectionStrength反射强度0.3~0.8与菲涅尔配合_FresnelPower菲涅尔系数3.0~8.0值越大只有边缘反射越强_MeniscusWidth弯月面宽度0.02~0.1根据瓶子尺寸调整_MeniscusColor弯月面色通常比液体颜色深略带暗色或环境色实操心得调节时先关掉波动和弯月面只调颜色、折射和反射让液体看起来像一块静止的、干净的果冻。然后再慢慢加上波动幅度一定要小。最后加上弯月面你会发现液体瞬间就“装”进瓶子里了。反射和菲涅尔是让液体表面“活”起来的关键多转动视角观察边缘反射的变化。4.3 性能优化与进阶技巧移动端优化慎用屏幕空间折射如非必需坚持使用立方体贴图折射。简化计算如果瓶子很小可以考虑在顶点着色器中计算简单的菲涅尔和反射向量而不是在片元着色器中进行。降低纹理精度法线贴图可以使用低分辨率如256x256因为波动细节要求不高。合并渲染如果瓶子是静态的考虑将液体和瓶身合并成一个模型使用一个多子材质的Shader减少Draw Call。进阶效果拓展内部气泡可以在片元着色器中根据一个基于世界坐标的3D噪声函数在液体体积内部非表面生成一些随机分布的、高光强烈的“气泡”点。这需要更复杂的片元着色器计算。温度计效应通过脚本控制液体模型的顶点高度即液面高度Shader中的弯月面效果会自动适应因为它是基于UV的y值计算的。多液体分层可以渲染多个液体模型叠加每个使用不同的颜色和密度并调整它们的渲染顺序和混合模式来模拟鸡尾酒的分层效果。这需要精心设计模型和Shader的透明度混合。5. 常见问题与排查实录在实际实现过程中你几乎一定会遇到下面这些问题。这里是我的排查笔记。5.1 效果异常问题排查表问题现象可能原因解决方案液体全黑或颜色异常环境立方体贴图未正确赋值或采样出错TBN矩阵计算错误导致法线错误。1. 检查_EnvCubemap是否关联了有效的Cubemap纹理。2. 在片元着色器中输出worldNormal可视化检查如return float4(worldNormal*0.50.5, 1)看法线是否在[-1,1]范围且变化合理。折射扭曲方向反了refract函数的入射方向或折射率比值用反了。确保refract的第一个参数是从表面指向相机的方向即-worldViewDir。检查折射率比值空气到液体是1.0 / IOR_liquid。没有波动效果法线贴图未赋值_WaveSpeed或_NormalStrength为0UV计算错误。1. 检查法线贴图类型是否为“Normal map”。2. 在Shader中直接输出waveUV或法线贴图采样结果检查是否有动画。弯月面位置不对或太生硬UV假设与模型实际UV不符smoothstep阈值设置不当。1. 在Shader中可视化UV如return float4(i.uv, 0, 1)确认液体顶部对应的V值。2. 调整_MeniscusWidth和smoothstep的阈值参数使其平滑过渡。液体边缘有锯齿或闪烁透明物体常见的深度排序Z-fighting问题。1. 确保液体模型与瓶内壁有微小的间隔如0.001个单位。2. 尝试稍微增大液体模型的缩放如1.001倍使其略微“嵌入”瓶壁。移动端上效果差或帧率低片元着色器计算过于复杂立方体贴图分辨率过高。1. 简化菲涅尔计算用1.0 - NdotV代替pow。2. 使用更低分辨率的立方体贴图如128x128。3. 考虑将波动计算移到顶点着色器。5.2 关于“a d3d11-compatible gpu”错误的特别说明在搜索热词中看到了“a d3d11-compatible gpu (feature level 11.0, shader model 5.0) is required to”这个错误。这通常与Shader本身无关而是Unity编辑器或项目图形API设置的问题。原因你的项目可能设置了仅使用DirectX 11及以上或Vulkan等高级图形API而你的显卡或显卡驱动不支持Shader Model 5.0。解决在Unity的Edit - Project Settings - Player - Other Settings中找到Graphics APIs列表。确保列表中包含了OpenGL或Direct3D11如果你的硬件支持等备选API并且将兼容性更广的API如OpenGL拖到列表前面。Unity会按顺序尝试使用这些API启动。5.3 从Built-in管线转向URP/HDRP如果你在使用Unity的URP或HDRP核心原理完全不变但代码写法需要适配。URP你需要创建一个URP的Unlit Shader Graph。上述所有计算都可以用节点实现TBN矩阵使用Normal Vector节点设置为Tangent Space配合Transform节点从Tangent到World。Reflect/Refract有现成的节点。Cubemap采样使用Sample Cubemap节点。菲涅尔使用Fresnel Effect节点。将各个部分用Lerp和Multiply节点连接起来。HDRP流程更复杂通常建议使用HDRP的Lit Shader模板并利用其强大的材质系统来配置折射、透射等物理属性可能比从头写更高效。这个Shader实现瓶内液体效果的方法本质上是一套经典的、基于物理光学模拟的着色方案。它平衡了效果与性能提供了丰富的参数供你调节出各种不同的液体。最重要的是通过拆解、实现和调试它的过程你能深刻理解折射、反射、法线贴图和菲涅尔效应这些基础图形学概念是如何在实时渲染中协同工作的。下次当你再看到游戏里那瓶晃动的朗姆酒时你就能会心一笑知道它背后大概是怎么一回事了。