ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity实时水墨画仿真:基于LBM流体算法的墨韵扩散实现

Unity实时水墨画仿真:基于LBM流体算法的墨韵扩散实现 简介流体仿真是计算机图形学中极具挑战性的方向而水墨画中墨迹在宣纸上的自然晕染本质上是一种低速流体在多孔介质中的渗流现象。传统贴图动画难以模拟墨水的实时扩散与浓淡变化格子玻尔兹曼方法LBM凭借其并行友好的碰撞-迁移模型成为实时流体模拟的高效选择。该方法无需显式求解压力泊松方程天然适配GPU并行计算可轻松集成到Unity的Compute Shader渲染管线中。本文从D2Q9模型原理出发讲解如何利用LBM驱动速度场与浓度场结合噪声纹理模拟纸张纤维的随机扰动在URP中实现可交互的水墨扩散效果。无论是游戏特效、数字艺术创作还是传统文化可视化应用该技术都能提供兼具物理真实感与艺术表现力的解决方案。文章完整拆解了数据布局、核函数编写、参数调优及常见踩坑点为开发者提供可直接参考的Unity实时流体实践路径。 水墨画仿真的难点从来不在笔刷形状像不像而在“墨”落纸之后那几分钟的脾气。墨刚落下时是一团浓色水分慢慢沿着宣纸纤维往外渗边缘出现毛刺中间出现浓淡不一的层次这个过程本质上是一个第二维空间里的流体在多孔介质中的缓慢流动。我这次在Unity里做实时水墨画仿真没有走贴图动效的老路而是在底层直接用格子玻尔兹曼方法LBM去算墨和水的交互做成一个可以交互控制、实时渲染的应用。整篇文章会把LBM的原理、Unity侧的数据布局、Compute Shader的完整落地、渲染层的处理以及我踩过的坑全部拆开讲一遍。如果你想用物理方法在Unity里做流体或者对艺术化渲染感兴趣这篇可以直接参考。1. 先说清楚为什么用LBM做水墨画仿真1.1 水墨画的本质是一个低速流体问题很多人第一次听到“用流体力学做水墨”会觉得过度工程化但实际上国画的很多效果——墨随水走、水多则积墨、纸纹引导渗化——本身就是流体在多孔介质里流动的宏观表现。你可以把墨量看成一个被动标量场它被速度场裹着走同时又因为浓度差往四周扩散遇到纸面纤维密度不同的区域扩散速度还会跟着变。落到方程层面这是一个二维不可压缩流动加上一个对流扩散方程的问题跟烟雾模拟、热扩散在数学结构上非常接近。既然本质是流体问题用物理模型去做就比靠美术堆贴图更靠谱。传统的贴图方案想要做出一笔下去墨量随时间扩散的效果往往要准备一堆纹理和动画帧一旦换一张纸、换一种墨量表现就生硬了。物理模型天然解决了这个问题初始条件改变后续演化是自动算出来的。1.2 LBM相比传统求解器的优势做流体模拟经典路线是直接求解Navier-Stokes方程比如欧拉法里常见的压力泊松方程迭代。这个方法在CPU上有大量成熟库但放到Unity里做实时应用而且目标平台可能是移动端或低端机器压力求解这个隐式步骤在GPU上写起来是很别扭的一件事迭代次数不够就出伪影收敛不稳定就得炸。LBM格子玻尔兹曼方法绕开了“显式求压力”这个环节。它不直接跟踪宏观的速度场和密度场而是跟踪每个格子上一组离散方向的粒子分布函数让流体的宏观密度和速度从这些分布函数里“自动涌现”出来。宏观压力信息隐含在密度场中不需要额外求解泊松方程整个更新流程就是碰撞和迁移两步天然适合GPU并行。每个格子只跟周围几个邻居交换数据正好对应纹理采样和RWTexture写入的场景写Shader的时候几乎感觉不到阻碍。另一个隐藏优势是边界条件。水墨画场景里画布有边界粒子效果中这些东西很烦但在LBM里只需要来一个反弹边界bounce-back在撞到边界时让分布函数反向就能模拟出不可穿透的边界简单直接。1.3 项目定位与运行环境这个项目的核心目标是做一个“实时可交互的水墨画应用”用户在屏幕上落笔墨量、水量实时进入仿真场随后墨迹在纸上自然扩散、晕染、干燥整个过程是可实时交互的。我的开发环境是Unity 2022.3 LTS渲染管线用的URP。模拟部分全部走Compute Shader没有用任何第三方流体插件渲染部分写了一个全屏ShaderURP的全屏Pass或Blit都行。模拟分辨率我采用512×512这个分辨率在桌面GPU上跑实时没有压力在移动端建议降到256×256。项目里我按两层墨去模拟——浓墨层和淡墨层两层都参与流体输运只是扩散系数和颜色映射不同这样能表现出“墨分五色”的层次感。2. LBM核心原理D2Q9到底在算什么2.1 九个方向、一堆权重、一条守恒律LBM的视角比较特别它不关心每个流体微团的精确运动路径而是问一个最朴素的问题——在某个格子位置、某个离散方向上有多少比例的粒子在运动对一个二维仿真我们可以把空间切成正方形格子每个格子里用一个向量来代表9个离散方向的粒子数。这个向量就是分布函数f9个方向合起来叫D2Q9模型。D2Q9的意思是“二维空间、9个速度方向”。中心方向的粒子是静止的四个正方向右、上、左、下权重比四个对角方向大。我在代码里维护一个方向表方便后面写迁移步方向索引速度向量权重0(0, 0)4/91(1, 0)1/92(0, 1)1/93(-1, 0)1/94(0, -1)1/95(1, 1)1/366(-1, 1)1/367(-1, -1)1/368(1, -1)1/36这里注意不同文献方向编号的顺序不一定完全一样实现时只要方向表、权重表、迁移读取逻辑保持自洽就行。真正要记住的是静止方向0占权重最大正方向次之对角方向最弱加在一起权重和为1这是平衡态公式能写出来的前提。2.2 碰撞和迁移两步构成一次迭代LBM每一次迭代分两步碰撞和迁移。为了理解你可以把格子想象成一个十字路口每个方向的进车量就是分布函数的值。碰撞步路口的车不会永远保持原方向司机会根据整体车流调整方向最终趋向某个均衡状态。在LBM里每个粒子的分布函数都会在一个时间步内朝“平衡态分布”靠拢。靠拢的快慢由松弛时间τ决定τ越小靠拢越快流体的粘性越小效果越“活”τ越大流体越黏滞墨迹扩散越沉闷。迁移步经过碰撞之后每个方向的粒子都带着自己的速度向量移动到相邻的格子上。这个操作在GPU上非常直白从当前格子读取某个方向的数值写到周围邻居格子里去。反过来写也可以——从邻居读取旧值到当前格子输出新值这样GPU上不会有写入冲突用起来更自然。2.3 宏观量、平衡态函数和松弛时间每次迭代前需要从9个方向的分布函数里还原出流体宏观量密度rho sum(f_i)宏观速度u sum(e_i * f_i) / rho这个还原过程就是求和在Compute Shader里用一个循环就能解决。碰撞步需要一个平衡态分布函数feqD2Q9模型下有标准公式feq_i w_i * rho * (1 3*(e_i·u) 4.5*(e_i·u)^2 - 1.5*(u·u))这里的e_i是第i个方向的速度向量w_i是权重。碰撞步写成f_i_new f_i_old (1/tau) * (feq_i - f_i_old)1/tau是松弛频率。这个式子就是BGK近似它说“每个方向的分布函数都会以固定比例朝平衡态靠近”。整个LBM的物理核心就藏在这个公式里。2.4 参数怎么拍粘度、速度、稳定性LBM有自己的单位系统参数不是随便拍的。比较关键的关系是格子粘度和松弛时间nu c_s^2 * (tau - 0.5)在D2Q9下c_s 1 / sqrt(3)所以nu大约等于(tau - 0.5) / 3。tau越接近0.5数值越容易不稳定一般取0.6到0.9之间比较稳。速度是个大坑。在格子单位制下宏观流速必须远小于声速c_s经验上最大速度不要超过0.1格/步。超过之后平衡态分布函数可能算出负值模拟就会在一帧之内铺满NaN。我在水墨场景里把用户输入的初始速度卡在0.05到0.08之间这样低速流场稳定又能让墨迹慢慢晕开。雷诺数Re u_max * L / nu决定流场是层流还是湍流。水墨的浸润效果更接近层流所以我在项目里把雷诺数控制在较低范围这样墨迹扩散的边缘会比较柔和不像喷泉那样乱跳。如果想让水感更强烈可以适当增大u_max或减小tau但不要一下子拉太多。3. Unity实现Compute Shader的完整落地3.1 数据布局9个分布函数放在哪里在GPU上直接用Texture2D数组存9张纹理是最容易理解的方式但9张纹理采样开销大显存也浪费。我用了一个更紧凑的方案把9个方向塞进3张RenderTexture。前两张用RGBA32_Float格式一张存f0到f3另一张存f4到f7第三张用R32_Float存f8。纹理通道映射格式_FTex0f0, f1, f2, f3RGBA32_Float_FTex1f4, f5, f6, f7RGBA32_Float_FTex2f8R32_Float由于迁移步要读写不同位置双缓冲是必须的。我定义了三组纹理用于读、三组用于写每完成一个Update核就让C#侧交换引用。直接在一张纹理上原地做迁移基本不可行因为迁移会覆盖还没用到的邻居数据结果会完全错掉。这个“双缓冲交换”看上去很基础但错起来很隐蔽经常程序不报错、画面却看不出任何变化就是因为交换步骤写漏了。C#侧创建纹理的代码量不大核心是设置enableRandomWrite和合适的格式fTex0Read new RenderTexture(width, height, 0, RenderTextureFormat.ARGBFloat); fTex0Read.enableRandomWrite true; fTex0Read.Create();3.2 LBMUpdate主核函数的写法Compute Shader主核函数我命名为LBMUpdate线程组是8×8。由于通道布局固定我把9个方向的迁移读取直接展开了。关键点是每个目标格子x处的方向i要从上一时刻的邻居格子x - e_i读到分布函数然后在当前位置做碰撞把新值写到当前格子的目标纹理。static const int2 dir[9] { int2(0, 0), int2(1, 0), int2(0, 1), int2(-1, 0), int2(0, -1), int2(1, 1), int2(-1, 1), int2(-1, -1), int2(1, -1) }; float equilibrium(int d, float rho, float2 u) { float2 e float2(dir[d]); float udu dot(e, u); float uu dot(u, u); return weight(d) * rho * (1.0 3.0 * udu 4.5 * udu * udu - 1.5 * uu); } [numthreads(8, 8, 1)] void LBMUpdate(uint3 id : SV_DispatchThreadID) { int2 xy int2(id.xy); // 边界格直接跳过用反弹边界处理 if (xy.x 0 || xy.y 0 || xy.x _SimWidth - 1 || xy.y _SimHeight - 1) return; // 从邻居读9个方向的分布函数 float f[9]; f[0] _FTex0Read[xy].r; f[1] _FTex0Read[xy - dir[1]].g; f[2] _FTex0Read[xy - dir[2]].b; f[3] _FTex0Read[xy - dir[3]].a; f[4] _FTex1Read[xy - dir[4]].r; f[5] _FTex1Read[xy - dir[5]].g; f[6] _FTex1Read[xy - dir[6]].b; f[7] _FTex1Read[xy - dir[7]].a; f[8] _FTex2Read[xy - dir[8]]; // 宏观量 float rho 0.0; float2 u 0.0; for (int d 0; d 9; d) { rho f[d]; u float2(dir[d]) * f[d]; } u rho 1e-6 ? u / rho : 0.0; // 碰撞并写回当前格子 float invTau 1.0 / max(_Tau, 0.501); _FTex0Write[xy].r f[0] invTau * (equilibrium(0, rho, u) - f[0]); _FTex1Write[xy].r f[4] invTau * (equilibrium(4, rho, u) - f[4]); // 其余方向同样处理 ... }每次Dispatch之后我要在C#里把读纹理和写纹理的引用交换循环下一帧。3.3 输入笔触与边界条件交互输入我单独写了一个ApplyInput核函数。用户用鼠标点击或拖动时我把屏幕坐标转换成模拟网格坐标在半径范围内同时修改浓度场和流场。常见的错误是只往浓度场里加墨量结果墨迹只是自己扩散缺少被水冲开的动势。水墨画里墨和水是绑定的笔触落下时要同时给速度场加一个指向笔锋方向的扰动哪怕速度值很小后续演化也会很不一样。笔触的具体做法在浓度场对应半径内加一个高斯分布的高浓度值在速度场内加一个由笔迹方向决定的初速度。用一个参数控制“墨量”和“水量”的比例水量多一点扩散效应更强墨色更容易晕开。边界用的是反弹边界。在网格四边以及更复杂的障碍物边上粒子撞上边界后应该按原路反弹回去。我在LBMUpdate里对边界格直接跳过然后在边界外手动把速度清成零密度用对称填充这样墨迹在撞到画面边缘时会自然停止不会出现墨色流出画布的问题。如果完全不管边界墨迹会从一侧流到另一侧形成周期镜像那就不像画布了。3.4 浓度场的水墨输运把墨量当成分离的浓度场可以在LBM之外单独处理。我用一个AdvectDiffuse核函数来完成浓度输运。思路是先根据流场速度做半拉格朗日对流把上一步的浓度采样到当前格子的“上游”位置然后再加一个扩散项。扩散系数不是常量而是乘了一个纸张噪声纹理这样墨色在不同区域的渗开速度不同会自然出现不规则的毛边。[numthreads(8, 8, 1)] void AdvectDiffuse(uint3 id : SV_DispatchThreadID) { int2 xy int2(id.xy); float2 uv (float2(xy) 0.5) / _SimSize; float2 vel _VelocityTex[xy].xy; float2 src float2(xy) - vel * _AdvectScale; float c _ConcentrationTexRead.SampleLevel(samplerLinear, (src 0.5) / _SimSize, 0).r; float paper _NoiseTex.SampleLevel(samplerLinear, uv * _PaperScale, 0).r; float diff _DiffusionBase * (0.3 paper * 0.7); // 拉普拉斯扩散 float lap _ConcentrationTexRead[xy int2(1, 0)].r _ConcentrationTexRead[xy - int2(1, 0)].r _ConcentrationTexRead[xy int2(0, 1)].r _ConcentrationTexRead[xy - int2(0, 1)].r - 4.0 * _ConcentrationTexRead[xy].r; c lerp(c, c lap * diff, _DiffuseSpeed); _ConcentrationTexWrite[xy] c; }扩散系数不能一步加太大否则浓度场会出现负值或棋盘格状的数值震荡。我让_DiffuseSpeed保持在0.02到0.05之间配合每帧多次迭代视觉上墨迹是慢慢“洇”开的而不是发毛。4. 渲染层把流体场变成“纸上墨迹”4.1 从浓度到墨色的映射模拟算出来的浓度场不能直接用因为水墨画的颜色不是简单的“黑透明度”。我的做法是在全屏Shader里把浓度值和浓度梯度结合起来同时考虑干燥程度。浓度越高颜色越深但不同区域的边界要更锐利才能做出“骨感”。核心映射思路类似finalColor mix(paperColor, inkColor, saturate(concentration * contrast edgeTerm * edgeStrength))其中edgeTerm用Sobel算子从浓度场提取墨迹边缘越明显看起来越像一笔画上去的而不是一团均匀的灰。这个映射还顺带处理了干墨和湿墨湿墨位置浓度梯度小过渡自然干墨位置梯度大边界毛糙。4.2 纸张纹理和纤维扰动水墨画仿真最大区分度其实在纸张上。宣纸不是一个光滑平面它有自己的纤维方向、厚度起伏和杂质点。我用了两张噪声资源一张是Perlin噪声用于控制扩散系数另一张是FBM分形噪声用于在渲染时扰动UV采样。渲染阶段的扰动比较狡猾直接扰动浓度场采样坐标墨迹边缘会产生一种类似“墨沿着纸纤维渗开”的锯齿感。但扰动太强会把线条弄碎太弱又看不出纸感。我试下来比较合适的方法是用FBM噪声乘以一个很微弱的幅度比如0.5个像素级别只让它影响墨色边缘不破坏整体结构。4.3 多层墨迹与留白处理两层墨浓墨和淡墨分别有独立的浓度场独立参与LBM和AdvectDiffuse。渲染阶段把它们混合起来同时控制最终输出的透明度。浓墨层的扩散系数小边缘保留得更完整淡墨层扩散系数大铺开的面积更广两层的叠加就形成类似“浓破淡”或“淡破浓”的层次。留白不是没有墨而是极浅的墨色加上明显的纸张纹理。我在最终颜色里加了一个threshold浓度低于某个值的区域会强制向纸张亮部靠拢并且把纸张纹理强度调高一倍模拟淡墨几乎看不出来但纸面纹理还若隐若现的效果。这个小细节让画面干净很多不至于整张画涂满灰色。5. 性能、调优和踩坑实录5.1 性能预算与参数实测这套系统在桌面端的性能开销主要来自LBMUpdate和AdvectDiffuse两个核。我在RTX 3060上实测512×512分辨率LBM一步约2ms浓度扩散一步约0.6ms两个核加起来不到3ms。每帧跑2到4步的话加上渲染和输入处理整体可以稳定在60帧。配置模拟分辨率每帧步数实测耗时帧率RTX 3060 512×512512×5122约5ms60fpsRTX 3060 512×512512×5124约9ms110fps上限中端移动端 256×256256×2562约4ms可能30fps移动端要优先把纹理格式降下来RGBA32F在移动端带宽压力很大。如果只做一层墨可以把LBM的方向纹理换成RGBA16F浓度场用R16F能省不少带宽代价是墨色层次会少一些。线程组大小我固定在8×8正好乘出64个线程一组的常见配置不同GPU上表现都不错。超过这个大小反而可能因为寄存器占用过高导致并行度下降。迭代步数不是越多越好每帧跑超过6步后墨迹的动态平衡阶段很快被消耗掉最终画面变成一片灰反而失去水墨的韵味。5.2 若干次调试事故记录第一个事故是满屏NaN这个几乎每个写LBM的人都会遇到一次。原因很典型我在笔触输入时给了速度场一个2.0的大脉冲直接超过LBM稳定极限一步就炸了。后来在输入阶段把速度钳制到0.08以下并且每次更新前对宏观速度做一个额外的clamp问题就彻底消失了。教训是LBM的速度限制不是理论建议是硬约束超过就是NaN。第二个事故是墨迹整体镜像。所有墨都往相反的斜角方向飘。查了半天发现是速度场采样时Y轴反了。纹理坐标的Y方向和LBM方向表里的Y方向不一致这个问题在纯计算阶段看不出来一接上渲染就露馅。解决办法是速度纹理写入前把Y分量乘个-1或者干脆在方向表里统一把Y取反。第三个事故是画面完全静止。代码没报错浓度场也有值但墨迹就是不动。检查了半天是C#侧双缓冲的交换写错了位置——我每帧都读同一套纹理写同一套纹理相当于每帧都在覆盖自己的数据。LBM的迁移步一旦没有双缓冲等于白算。这个bug也让LBM的调试变得很依赖可视化我把速度场直接渲染成颜色立刻就能发现速度场根本没在变化。第四个事故是墨迹呈规则的棋盘格像像素风马赛克。这个多是扩散步的数值振荡原因是扩散系数过大或者单步时间太长。把_DiffuseSpeed降下来同时把时间步拆成多次小步就能消除。5.3 常见问题速查表症状原因解决方案满屏NaN输入速度过大或tau过小钳制速度小于0.1tau设置0.7~0.9墨迹整体方向反了纹理坐标Y轴与LBM方向表不一致检查速度采样方向Y值取反画面静止无变化双缓冲纹理未交换确认每帧交换Read/Write纹理引用墨迹像棋盘格浓度扩散步数值振荡降低扩散系数增加小步迭代移动端发糊RGBA32F纹理带宽压力大换RGBA16F或拆成多个单通道纹理落笔毫无反应输入速度过低流场没被扰动同时扰动浓度场和速度场而非只加墨量纸上效果太干净缺少纸张噪声在扩散系数和渲染UV扰动里加入噪声最后再分享一个小技巧把那两层的浓度场混合结果保存下来然后在最终渲染的UV坐标上额外加一个极微弱的、随时间缓慢变化的噪声扰动画面就会产生一种宣纸在呼吸的生动感这个技巧对成品感的提升非常即时值得一试。本文还有配套的精品资源点击获取
返回列表