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

资讯详情

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

Unity Shader实现管道流体模拟:性能优化与视觉艺术结合

Unity Shader实现管道流体模拟:性能优化与视觉艺术结合 1. 项目概述当水流在管道中“思考”在Unity里模拟流体尤其是管道内的流动一直是个挺有意思的挑战。你可能会想到用粒子系统或者更复杂的流体动力学计算。但今天要聊的是另一种更“轻量”但效果惊艳的思路用Shader着色器来模拟。这个“管道流动模拟Shader”项目核心就是用屏幕后处理Post-Processing或自定义材质的方式在2D或3D的管道纹理上实时“画”出流动的液体效果。它不模拟真实的物理而是用视觉欺骗的艺术让你在游戏里看到液体在管道中奔腾、汇聚、分流甚至带有颜色混合和流速变化。这玩意儿适合谁如果你是独立游戏开发者想做一款带有复杂管道系统的工厂建造、化学实验或者资源管理游戏比如《异星工厂》、《幸福工厂》那种感觉但又不想被复杂的物理模拟拖垮性能那这个Shader方案就是你的菜。它也适合技术美术TA或者对Shader编程感兴趣的开发者想深入理解如何用数学和纹理操作来创造动态的视觉幻觉。说白了它用GPU的并行计算能力以极低的CPU开销实现了过去需要大量运算才能达成的动态效果。2. 核心思路与方案选型为什么是Shader为什么选择用Shader而不是更“正统”的粒子或物理流体这背后是性能、控制力和视觉风格的综合考量。2.1 性能的绝对优势在移动端或需要大量管道实例的场景中每个管道都用粒子系统或物理模拟Draw Call和计算开销会指数级增长。而一个精心编写的Shader尤其是作为屏幕后处理运行时无论屏幕上有多少根管道它只对整个屏幕渲染一次或几次。所有的流动计算都在GPU的像素着色器Fragment Shader中并行完成帧率可以保持得非常稳定。我曾在一个模拟城市水网的Demo里测试用粒子系统超过50根管道就开始卡顿而换成Shader方案200根管道同时流动还能维持在60FPS以上。2.2 艺术化的完全控制物理模拟追求真实但游戏往往需要风格化、可读性强的表现。Shader让你完全掌控流动的“规则”。你可以轻松定义流速让主管道流得快支管道流得慢。流向通过一张流向图Flow Map精确控制每个像素上液体的流动方向轻松实现分流、涡流。外观液体的颜色、透明度、边缘模糊程度模拟粘稠度、甚至是表面波纹通过噪声纹理都可以通过参数实时调整。你想让液体发光、脉冲或者根据管道内容物改变颜色在Shader里就是几行代码的事。2.3 方案选型后处理 vs 自定义材质实现管道流动Shader主要有两种架构各有适用场景。方案一屏幕后处理Shader这是最常用、最灵活的方式。你需要一个摄像机渲染出管道的静态几何体比如一些拼接好的Quad或模型这个渲染结果我们称为“管道蒙版图”。然后在后处理Shader中读取这张蒙版图和上一帧的流动状态图进行流动计算输出新的流动状态并叠加渲染到屏幕上。优点与场景中管道物体的数量和解耦一套Shader处理所有管道。非常适合全屏范围的、复杂的管道网络可视化。缺点需要管理渲染纹理Render Texture来传递状态实现稍复杂。如果管道与其他场景物体需要深度交互如液体溢出效果会变得棘手。选型理由当你的游戏核心就是俯瞰整个管道网络需要全局的统一流动规则和高效表现时必选此方案。方案二基于模型的自定义Unlit Shader为每个管道模型单独赋予一个材质使用自定义Shader。这个Shader根据模型的UV坐标和时间来计算当前像素的液体表现。优点实现简单每个管道独立易于控制单个管道的属性如单独开关、改变颜色。易于与场景深度、其他物体进行交互。缺点管道实例多时Draw Call会增加。跨管道的液体连续流动效果比如液体从一根管子流到另一根实现起来比较困难需要精心设计UV或传递参数。选型理由适用于管道数量相对固定、且每个管道需要独立交互逻辑的场景比如第一人称视角的维修模拟游戏玩家可以点击单个管道进行操作。对于大多数追求整体流动感和性能的案例我强烈推荐从屏幕后处理方案入手。它代表了这类效果的核心思想也更能体现Shader编程的威力。下面的内容也将主要围绕这种方案展开。3. 核心原理与Shader实现拆解理解了“为什么”我们深入“怎么做”。一个基础的管道流动Shader其核心原理可以概括为“状态保持”与“平流模拟”。3.1 状态保持双缓冲渲染纹理流体是连续的。要模拟流动Shader必须记住上一帧液体在哪里。这需要用到“状态保持”。我们无法在Shader内直接修改变量并跨帧保存所以需要借助渲染纹理Render Texture作为“记忆体”。标准的做法是使用**双缓冲Double Buffering**技术我们准备两张渲染纹理_CurrentBuffer和_PreviousBuffer。在本帧Shader读取_PreviousBuffer上一帧的状态进行计算将结果输出到_CurrentBuffer本帧的新状态。渲染完成后交换两张纹理的引用将_CurrentBuffer设为下一帧的_PreviousBuffer如此循环。在Unity中这通常通过C#脚本管理。脚本创建两张Render Texture并在每帧通过Graphics.Blit函数以特定的Shader Material将一张Blit到另一张同时交换它们。3.2 平流模拟核心着色器算法平流Advection是流体模拟中最基础的概念简单说就是“物质随着速度场移动”。在我们的Shader里对于屏幕上的每一个像素代表一个可能被液体占据的位置获取速度根据该像素的位置从一张预设的**流向图Flow Map**中采样。流向图是一张RGBA纹理R和G通道-1到1编码了水平U和垂直V方向的流动速度向量。B和A通道可以编码其他信息如流速系数、管道ID等。回溯采样要得到当前像素本帧的颜色液体存在与否及颜色我们不是向前看而是向后看。根据速度向量计算出上一帧时流动到当前位置的液体“来自”哪个位置即回溯位置。float2 previousUV i.uv - _FlowMapSpeed * _FlowVector * _DeltaTime;这里i.uv是当前像素的UV坐标_FlowVector是从流向图采样的速度向量_FlowMapSpeed是全局速度系数_DeltaTime是帧时间差用于保证不同帧率下流动距离一致。采样历史状态用计算出的previousUV去上一帧的状态纹理_PreviousBuffer中进行采样。这个采样得到的颜色就是当前像素本帧应该显示的颜色。这个过程模拟了液体从上游流到下游。添加源与衰减仅有平流液体会永远循环。我们需要源Source从一张“注入点”纹理例如管道入口处为白色读取信息如果有注入则强制将当前像素颜色设置为液体颜色。衰减Decay每帧对采样的历史颜色乘以一个略小于1的系数如0.99模拟液体在流动过程中的微量耗散或粘滞损失避免数值溢出。3.3 关键参数与纹理设计一个可调节、效果丰富的Shader离不开精心设计的参数和输入纹理。_FlowMap流向图这是效果的灵魂。通常由美术在工具如Houdini、专门的Flow Map绘制工具甚至Photoshop配合法线图中生成。它定义了管道网络的拓扑结构和流动方向。在主管道上向量指向出口在分叉处向量平滑地分开。确保流向图是无缝循环的避免流动出现突变。_PipeMask管道蒙版图定义哪些区域是管道可流动区域。通常是一张黑白图白色代表管道内部。在Shader中会用它来限制流动计算仅在管道区域内生效管道外区域直接输出背景色或透明。_SourceMap注入源图定义液体从哪里注入。可以是动态的比如通过脚本根据游戏逻辑实时修改某张Render Texture的特定像素为白色表示此处开始注入液体。_ColorRamp颜色渐变图根据液体的“浓度”即状态纹理的颜色亮度或流速映射出不同的颜色。可以用来表现不同温度的液体红-蓝或者不同纯度的物质。速度与粘度参数_Speed控制全局流速。_Viscosity粘度参数可以通过影响平流回溯时的采样模糊例如对previousUV附近多个点采样并混合来模拟粘稠液体的模糊、扩散感。注意流向图Flow Map的生成是关键也是难点。对于规则网格状管道可以用程序化噪声结合向量场运算来生成。对于复杂、美术设计的管道网络建议使用专门的工具离线生成并作为资源导入。一个取巧的办法是用管道的法线贴图或高度图进行简单的索贝尔Sobel边缘检测再旋转90度来近似生成切线方向的流向但这只适用于简单管道。4. 完整实现步骤与代码解析下面我们以一个Unity URP通用渲染管线环境下的屏幕后处理流动Shader为例拆解实现步骤。假设我们已经有了绘制好的管道蒙版图和流向图。4.1 第1步创建与管理渲染纹理C#脚本首先我们需要一个C#脚本来管理双缓冲和渲染循环。这个脚本挂载在摄像机上。using UnityEngine; using UnityEngine.Rendering; [RequireComponent(typeof(Camera))] public class PipeFlowController : MonoBehaviour { public Shader flowShader; // 我们的流动计算Shader public Texture2D pipeMask; // 管道蒙版图 public Texture2D flowMap; // 流向图 public Texture2D sourceMap; // 注入源图可动态更新 public Color liquidColor Color.cyan; public float flowSpeed 0.5f; public float viscosity 0.1f; public float decayRate 0.995f; private Material _flowMaterial; private RenderTexture _bufferA; private RenderTexture _bufferB; private bool _useBufferAAsSource true; void Start() { // 检查Shader并创建材质 if (flowShader null || !flowShader.isSupported) { enabled false; Debug.LogError(Flow Shader is missing or not supported.); return; } _flowMaterial new Material(flowShader); // 创建两张用于双缓冲的Render Texture int width Screen.width; // 可根据需要降低分辨率以提升性能 int height Screen.height; _bufferA CreateRenderTexture(width, height); _bufferB CreateRenderTexture(width, height); // 初始化纹理为黑色无液体 Graphics.Blit(Texture2D.blackTexture, _bufferA); Graphics.Blit(Texture2D.blackTexture, _bufferB); // 将必要的纹理和参数传递给材质 _flowMaterial.SetTexture(_PipeMask, pipeMask); _flowMaterial.SetTexture(_FlowMap, flowMap); UpdateMaterialProperties(); } RenderTexture CreateRenderTexture(int w, int h) { var rt new RenderTexture(w, h, 0, RenderTextureFormat.ARGBFloat); // 使用Float格式保证精度 rt.filterMode FilterMode.Bilinear; rt.wrapMode TextureWrapMode.Clamp; // 或Repeat取决于流向图设计 rt.Create(); return rt; } void UpdateMaterialProperties() { if (_flowMaterial null) return; _flowMaterial.SetColor(_LiquidColor, liquidColor); _flowMaterial.SetFloat(_FlowSpeed, flowSpeed); _flowMaterial.SetFloat(_Viscosity, viscosity); _flowMaterial.SetFloat(_Decay, decayRate); // 动态更新注入源图 if (sourceMap ! null) _flowMaterial.SetTexture(_SourceMap, sourceMap); } void OnRenderImage(RenderTexture source, RenderTexture destination) { UpdateMaterialProperties(); // 确定“上一帧”和“当前帧”缓冲区 RenderTexture previousBuffer _useBufferAAsSource ? _bufferA : _bufferB; RenderTexture currentBuffer _useBufferAAsSource ? _bufferB : _bufferA; // 将“上一帧状态”和“原始输入纹理”传递给Shader _flowMaterial.SetTexture(_PreviousBuffer, previousBuffer); _flowMaterial.SetTexture(_MainTex, source); // 原始场景图用于混合 // 执行流动计算结果输出到“当前帧缓冲区” Graphics.Blit(previousBuffer, currentBuffer, _flowMaterial, 0); // Pass 0 是计算Pass // 将计算后的流动效果与原始场景图混合输出到屏幕 _flowMaterial.SetTexture(_FlowBuffer, currentBuffer); Graphics.Blit(source, destination, _flowMaterial, 1); // Pass 1 是混合/显示Pass // 交换缓冲区 _useBufferAAsSource !_useBufferAAsSource; } void OnDestroy() { // 清理资源 if (_bufferA ! null) _bufferA.Release(); if (_bufferB ! null) _bufferB.Release(); if (_flowMaterial ! null) Destroy(_flowMaterial); } }4.2 第2步编写流动计算ShaderHLSL接下来是核心的Shader部分。我们使用Shader Graph或手写HLSL。这里展示手写HLSL的核心逻辑。// 这是一个简化的后处理Shader框架包含两个Pass Shader Hidden/PipeFlow { Properties { _MainTex (Base (RGB), 2D) white {} _PipeMask (Pipe Mask, 2D) white {} _FlowMap (Flow Map, 2D) bump {} _SourceMap (Source Map, 2D) black {} _PreviousBuffer (Previous State, 2D) black {} _FlowBuffer (Current Flow Buffer, 2D) black {} _LiquidColor (Liquid Color, Color) (0, 0.5, 1, 1) _FlowSpeed (Flow Speed, Float) 0.5 _Viscosity (Viscosity, Range(0, 0.5)) 0.05 _Decay (Decay, Range(0.9, 1.0)) 0.995 } SubShader { Cull Off ZWrite Off ZTest Always // Pass 0: 流动模拟计算Pass (渲染到_currentBuffer) Pass { Name FLOW_SIMULATION HLSLPROGRAM #pragma vertex vert #pragma fragment frag_simulation #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert (Attributes v) { Varyings o; o.pos TransformObjectToHClip(v.vertex.xyz); o.uv v.uv; return o; } TEXTURE2D(_PreviousBuffer); SAMPLER(sampler_PreviousBuffer); TEXTURE2D(_FlowMap); SAMPLER(sampler_FlowMap); TEXTURE2D(_PipeMask); SAMPLER(sampler_PipeMask); TEXTURE2D(_SourceMap); SAMPLER(sampler_SourceMap); float _FlowSpeed; float _Viscosity; float _Decay; float _DeltaTime; // 由C#脚本每帧传递 half4 frag_simulation (Varyings i) : SV_Target { // 1. 采样流向图解码速度向量 (从[0,1]映射到[-1,1]) half4 flowData SAMPLE_TEXTURE2D(_FlowMap, sampler_FlowMap, i.uv); half2 flowVector (flowData.rg * 2.0 - 1.0); // 2. 平流回溯计算上一帧的来源位置 float2 advectedUV i.uv - _FlowSpeed * flowVector * _DeltaTime; // 3. 粘度模拟对回溯位置进行轻微模糊采样可选 half4 previousColor SAMPLE_TEXTURE2D(_PreviousBuffer, sampler_PreviousBuffer, advectedUV); if (_Viscosity 0.001) { // 简单实现采样周围四个点混合 float2 offset _Viscosity * 0.01; previousColor SAMPLE_TEXTURE2D(_PreviousBuffer, sampler_PreviousBuffer, advectedUV float2(offset.x, 0)); previousColor SAMPLE_TEXTURE2D(_PreviousBuffer, sampler_PreviousBuffer, advectedUV float2(-offset.x, 0)); previousColor SAMPLE_TEXTURE2D(_PreviousBuffer, sampler_PreviousBuffer, advectedUV float2(0, offset.y)); previousColor SAMPLE_TEXTURE2D(_PreviousBuffer, sampler_PreviousBuffer, advectedUV float2(0, -offset.y)); previousColor / 5.0; } // 4. 衰减 previousColor.rgb * _Decay; // 5. 注入新液体根据源图 half4 source SAMPLE_TEXTURE2D(_SourceMap, sampler_SourceMap, i.uv); half sourceStrength source.r; // 假设用R通道表示注入强度 // 如果源图有强度则用源强度混合液体颜色到历史颜色中 previousColor.rgb lerp(previousColor.rgb, _LiquidColor.rgb, sourceStrength); // 6. 管道蒙版裁剪只在管道区域内保留液体 half mask SAMPLE_TEXTURE2D(_PipeMask, sampler_PipeMask, i.uv).r; previousColor.rgb * mask; // 管道外强制为0 // 确保Alpha通道用于后续混合或作为数据载体 previousColor.a saturate(max(max(previousColor.r, previousColor.g), previousColor.b)); // 用亮度代表“液体存在感” return previousColor; } ENDHLSL } // Pass 1: 最终合成与显示Pass (渲染到屏幕) Pass { Name FINAL_BLEND HLSLPROGRAM #pragma vertex vert #pragma fragment frag_final #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert (Attributes v) { /* 同Pass0 */ } TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); TEXTURE2D(_FlowBuffer); SAMPLER(sampler_FlowBuffer); TEXTURE2D(_PipeMask); SAMPLER(sampler_PipeMask); half4 _LiquidColor; half4 frag_final (Varyings i) : SV_Target { // 采样原始场景颜色 half4 sceneColor SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); // 采样流动缓冲区颜色 half4 flowColor SAMPLE_TEXTURE2D(_FlowBuffer, sampler_FlowBuffer, i.uv); // 采样管道蒙版 half mask SAMPLE_TEXTURE2D(_PipeMask, sampler_PipeMask, i.uv).r; // 合成在管道区域内用流动颜色叠加或混合到场景上 // 这里使用简单的屏幕空间叠加更高级的可以做基于深度的混合 half3 finalColor sceneColor.rgb; if (flowColor.a 0.01) { // 如果有液体 // 将流动亮度与液体颜色结合然后以“叠加”或“加法”方式混合 half3 liquidTint flowColor.rgb * _LiquidColor.rgb; // 简单的加法混合可以替换为更复杂的混合模式 finalColor lerp(finalColor, finalColor liquidTint * 0.7, mask * flowColor.a); } return half4(finalColor, sceneColor.a); } ENDHLSL } } }4.3 第3步制作与配置输入纹理这是艺术驱动的一步。你需要三张核心纹理管道蒙版图PipeMask在建模软件或绘图软件中将你的管道网络渲染成一张纯白色的图背景为黑色。确保UV展开正确与游戏中的管道布局匹配。流向图FlowMap这是最需要技巧的。可以使用诸如Flowmap Painter这样的Unity插件或外部工具如Krita Flowmap插件来绘制。绘制原则是箭头方向代表流动方向箭头长度颜色亮度代表流速。在分叉处流向要平滑过渡。注入源图SourceMap通常初始为全黑。你可以通过脚本在运行时根据游戏逻辑如玩家打开了阀门在对应的UV位置将这张Render Texture的像素绘制成白色。将这三张图拖入Unity确保它们的导入设置Wrap Mode, Filter Mode符合你的Shader预期例如流向图可能需要Repeat模式以实现无缝循环。5. 调试、优化与常见问题即使代码写完了效果可能也不尽如人意。下面分享一些调试心得和避坑指南。5.1 效果调试技巧液体不流动或反向流动首先检查流向图。在Shader中可视化流向图直接返回flowVector的颜色。确保箭头指向你期望的流动方向。记住平流是“回溯”所以流向箭头应该指向下游。如果你的液体往反方向流把流向图水平翻转或调整向量解码公式。流动出现“断层”或“条纹”这通常是数值精度问题或纹理滤波导致的。尝试确保Render Texture使用ARGBFloat格式。在平流计算中对advectedUV进行微小的随机偏移基于噪声纹理或像素位置哈希可以打破规则条纹形成更自然的流动。这被称为“微扰动Jitter”。检查流向图本身是否有不连续的区域。液体在管道边缘渗出这是蒙版裁剪不严或平流回溯时采样到管道外区域导致的。在计算advectedUV后可以采样一次管道蒙版如果回溯点已经在管道外则直接丢弃或衰减该次流动贡献。也可以在最终混合时使用更严格的蒙版边缘比如对蒙版图做一次侵蚀处理。性能热点主要开销在平流采样和可能的多次纹理采样粘度模糊。如果管道区域只占屏幕一小部分可以尝试使用scissor rect或command buffer只对管道区域进行计算。降低流动缓冲区的分辨率如半分辨率也能极大提升性能且对视觉效果影响不大。5.2 常见问题排查表问题现象可能原因排查与解决方法屏幕全黑/无效果Shader未编译或材质未正确赋值Render Texture创建失败。检查Unity Console是否有Shader编译错误。在Frame Debugger中查看渲染事件确认Blit操作是否执行材质参数是否传递。液体静止不动_DeltaTime未传递或为0_FlowSpeed为0流向图全为中性值如0.5。在C#脚本中确保每帧传递Time.deltaTime给Shader。检查流向图纹理确认其R、G通道值有变化非0.5。流动速度不稳定帧率相关平流计算未乘_DeltaTime。确保在Shader的advectedUV计算中乘以_DeltaTime和_FlowSpeed。液体颜色异常或过暗颜色混合模式错误衰减(_Decay)系数过小。检查最终混合Pass的公式。确保_LiquidColor是亮色。调大_Decay值接近1。管道外出现液体残留蒙版裁剪在平流后执行历史液体已扩散出去。在平流计算前先采样目标位置的蒙版值如果为0则直接返回黑色。或者采用“边界固化”技术在流向图边缘设置零速度。高粘度下效果模糊一片粘度采样半径过大或采样次数过多。减小_Viscosity参数。将简单的多点采样改为使用高斯模糊核或降低粘度Pass的渲染分辨率。5.3 进阶优化与扩展当基础效果跑通后可以考虑以下方向提升多流体交互使用不同的颜色通道R、G、B或额外的渲染纹理来模拟多种不同液体。在平流和源注入时分别处理并在混合时根据规则进行颜色混合如红色蓝色紫色。基于物理的改进引入简单的压力场概念。在另一个Pass中根据液体密度状态纹理的亮度计算一个压力梯度并反过来微调流向图模拟液体从高压区流向低压区可以实现更自主的流动。与粒子系统结合用Shader模拟整体的流动体和颜色但在管道接口、阀门处用粒子系统溅射一些水花、气泡等细节粒子实现动静结合效果更佳。UV动画与变形不仅仅是颜色流动可以结合顶点着色器或曲面细分根据流动状态轻微偏移管道模型的顶点模拟管道被液体充盈时的膨胀感。实现这个管道流动Shader的过程就像是在教GPU如何“画”一幅动态的画。它不需要知道牛顿定律只需要遵循你设定的几条简单规则。这种将复杂现象降维、用视觉算法表达的思想在技术美术领域非常普遍。调试阶段可能会因为一两个参数的符号或范围不对而头疼半天但当你看到第一股颜色沿着预设的路径奔腾起来时那种成就感是实实在在的。最关键的是这套框架的潜力很大一旦掌握了状态传递和平流的核心你可以把它应用到烟雾、云层、魔法轨迹等任何需要动态场模拟的地方思路都是相通的。
返回列表