1. 先搞清楚“后处理”到底在处理什么
1.1 从求解器吐出数字到报告里那张图,中间有三层损耗
做流场可视化后处理这件事,最容易被低估的地方在于:很多人以为它只是“把 u、v 画成箭头”。实际情况是,从求解器或者实验设备输出的原始数据,到你最终放进报告里那张能自解释的图,中间要经过三次信息衰减,每一次都可能把物理规律弄丢。
第一次衰减发生在数据落地阶段。求解器为了压缩体积,往往输出节点值加拓扑关系,或者干脆就是一堆无序散点。这些数据的坐标系原点、单位制、网格索引顺序,跟你脑子里想的完全不是一回事。我见过太多次有人把毫米当米直接丢进quiver,结果箭头长度全部挤成一个点,然后开始怀疑是不是算错了。
第二次衰减发生在插值环节。流场量本质上是定义在离散点上的,但streamline、streamslice、isosurface这些函数默认你要给它一个规则网格。散点转网格这一步如果用最近邻,涡核立刻被抹平;如果用线性,边界外插又会飞出离谱的值。选错插值方式,等于在图上凭空造出一个不存在的涡。
第三次衰减发生在视觉编码上。同一个速度场,用jet色图会让中间段出现虚假的“台阶”,用自动缩放的箭头会让慢速区和高速区看起来一样热闹,用默认的坐标轴比例会把圆形涡压成椭圆。
所以这篇内容想讲的核心就一件事:MATLAB 流场可视化后处理,真正的工作量不在画图那一行代码,而在前面这三层损耗的控制上。这篇适合两类人看:一类是刚拿到流场数据、quiver画出来惨不忍睹的新手;另一类是已经能画图,但总觉得图“说不出话”、想让图自己讲物理的老手。下面所有的代码都是我实际跑过、能直接抄的,参数取值也会解释清楚为什么是那个数。
1.2 我为什么最终固定在“统一数据容器 + 插值对象 + 句柄图形”这条路线上
MATLAB 里做流场可视化,能走的路其实不少。有人用 PDE Toolbox 自带的pdeplot,有人用第三方工具箱,也有人干脆导出到别的软件里做。
我试过一圈之后,固定下来的组合是:自己定义一个统一的数据容器结构体,用scatteredInterpolant做散点到网格的转换,剩下全部走底层句柄图形(handle graphics)。理由有三个,都不是拍脑袋。
第一,scatteredInterpolant是一次构建、多次查询的对象。同样是插值一百万次,griddata每次调用都要重新建一次三角剖分,而scatteredInterpolant只在构造时建一次。我在一个约 40 万散点的 PIV 数据上实测过,用griddata连续做 5 次不同分辨率的插值要接近 4 分钟,换成scatteredInterpolant之后总共 20 秒出头。差距不是一点点,是让你敢反复调参和不敢调参的区别。
第二,句柄图形意味着图上每一个元素都是可寻址的对象。色标的刻度、流线的透明度、箭头头部的比例、坐标轴的字号,全都能在画完之后单独改,不用重画。这一点在需要反复微调出图的时候特别重要,否则每次改个字号都要重跑一遍插值和绘图,人会疯。
第三,统一数据容器让脚本可以复用。今天处理的是二维 PIV,明天换成三维 CFD 结果,只要把数据塞进同一个结构体字段,后面的绘图函数几乎不用动。这个习惯带来的长期收益,比任何单次优化的技巧都大。
注意:不要一上来就写绘图代码。先把数据读进来、单位统一、坐标对齐、量纲检查这四件事做完,再动手画。这个顺序反了,后面调图的时间会翻好几倍。
2. 数据落地:读取与结构组织
2.1 常见数据来源与对应的读取策略
流场数据的来源五花八门,但落到文件格式上其实就那么几类。我按踩坑频率排一下。
纯文本 CSV 或 DAT 最常见,也最容易出事。它的问题是大文件和编码。几十万行的 CSV 用readmatrix会慢,用readtable内存翻倍,而且很多求解器输出的分隔符是制表符加多个空格混排。我一般的做法是先detectImportOptions探一下,再决定列类型,避免数值列被识别成字符串。
MAT 文件是最省心的,但要注意里面的变量命名往往跟求解器版本绑定,同一个量在不同版本里可能叫U、u_vel、Velocity_X。所以我习惯写一小段字段别名映射,而不是在每个脚本里手动改。
VTK 或 Tecplot 这类结构化网格格式,MATLAB 原生读起来不算顺手。如果数据量不大,我倾向于先在源头导成 CSV 或者 MAT;如果一定要在 MATLAB 里读 VTK,注意它的字节序和数据类型标记,二进制 VTK 里float和double读错一个字节,整场数据会变成天文数字。
自研二进制格式最麻烦,但也最好办——因为你总能找到写它的那个人,问清楚头部的字节数和字段顺序。我遇到过最坑的一次是头部里有个“保留字段”实际被用来存时间步,跳过它就等于所有帧叠在一起。
% 一个通用的读取壳子,按扩展名分派 function raw = readFlowFile(fname) [~, ~, ext] = fileparts(fname); switch lower(ext) case '.mat' S = load(fname); fn = fieldnames(S); raw = S.(fn{1}); case {'.csv', '.dat', '.txt'} opt = detectImportOptions(fname); opt.VariableNamingRule = 'preserve'; raw = readmatrix(fname, opt); case '.vtk' error('VTK 建议先在源头转成 CSV 或 MAT 再处理'); otherwise error('未支持的文件类型: %s', ext); end end这段代码本身没什么技术含量,重点是里面那条error。我宁可让脚本在这里直接停下来,也不愿意在 VTK 解析上耗掉半天,最后还得回头怀疑数据。工程上把边界画清楚,比把功能做全更重要。
2.2 统一数据容器的字段设计
数据读进来之后,我做的第一件事是把它塞进一个固定字段名的结构体。这个结构体的设计有几个讲究,都是被坑出来的。
坐标系必须是右手系、单位必须统一到米和秒。有些实验数据用毫米、有些用像素,还有的用归一化坐标。统一单位这件事看起来琐碎,但如果你不做,后面计算涡量的时候dv/dx会离谱到没法看,而且你还得花时间去找原因。
速度分量顺序必须是 u、v、w 对应 x、y、z。我见过不少数据文件里第二列是 v 但实际是 y 方向的负值,因为坐标系是左手系。这类问题在图上表现为流线整体“往反方向跑”,非常隐蔽。
必须保留原始点坐标和连接关系。插值会生成新网格,但原始散点在某些场景下还是要用的,比如散点密度图、或者用原始数据做误差校验。
% 标准容器:所有后处理脚本都只认这个结构 flow = struct(); flow.x = X(:); % 单位: m flow.y = Y(:); flow.z = Z(:); % 二维时留空 [] flow.u = U(:); % 单位: m/s flow.v = V(:); flow.w = W(:); flow.p = P(:); % 静压, Pa, 没有就 [] flow.t = 0.0; % 时间, s flow.meta = struct('source', fname, 'unit', 'SI', 'gridType', 'scatter');这里用(:)强行拉成列向量,是个小习惯但很值。因为后面scatteredInterpolant要求输入是等长向量,如果原始数据是矩阵,维度对不上报的错会非常难懂。提前统一成列向量,等于把一类错误提前消灭。
提示:如果你的数据里带时间序列,不要用三维数组存所有帧,内存会炸。我一般用
cell存每帧的容器,或者用matfile做按需读取。一帧 50 万点、200 帧的数据,全部读进内存轻松超过 8 GB。
3. 二维流场:从箭矢图到能讲物理的流线图
3.1 quiver 的三个隐形参数,决定了图是不是“一片红”
新手画流场,第一行代码基本都是quiver(X,Y,U,V),然后得到一张箭头互相重叠、完全看不出结构的图。问题几乎百分百出在自动缩放上。
quiver默认AutoScale是'on',AutoScaleFactor是 0.9。意思是它会把所有箭头按数据范围自动缩放,让最长的箭头大概占一个网格间距的 0.9 倍。这在均匀流场里没问题,但在有强剪切或者高速射流的场里,自动缩放会让大部分低速区的箭头缩成一个点,完全消失。
我的处理方式是三选一,按场景来。
场景一:想看清整体的流向结构,不在乎速度绝对大小。这时候保持AutoScale打开,但把AutoScaleFactor调到 1.2 左右,让箭头稍微超出网格一点点,视觉上更连续。
场景二:想比较不同时刻或不同算例的速度大小。这时候必须把AutoScale关掉,用Scale参数直接给一个固定缩放系数,保证所有图用的是同一把尺子。
场景三:速度动态范围超过两个数量级,比如既有主流区又有近壁低速区。这时候任何线性缩放都救不了,我的做法是对速度取对数或者开方之后再画,并在图注里写清楚。
% 关键:先算一个稳定的缩放系数,别让每张图自己变 umax = max(hypot(flow.u, flow.v)); dx = median(diff(unique(gridX))); % 网格间距 scale = 0.8 * dx / umax; figure('Position',[100 100 900 600]); quiver(Xq, Yq, Uq, Vq, scale, ... 'AutoScale','off', ... 'MaxHeadSize', 0.4, ... 'Color', [0.25 0.25 0.25], ... 'LineWidth', 0.6); axis equal; axis tight; box on;MaxHeadSize这个参数很少被提起,但它决定了箭头头部的相对大小。默认值在密集网格下会让箭头头部糊成一片黑,调到 0.3 到 0.5 之间会清爽很多。axis equal也是必须的——坐标轴比例不一致,圆形涡会变成椭圆,方向判断直接失效。
另一个常被忽略的是Pivot,默认在箭头尾部,改成'mid'会让矢量图看起来更对称,适合做对称结构的流场。
3.2 散点转网格:scatteredInterpolant 的构建与查询策略
streamline、streamslice、contourf这些函数都要规则网格。所以散点数据的第一步一定是插值。
这里有个关键选择:插值方法。scatteredInterpolant支持'linear'、'nearest'、'natural'三种。我的经验是按物理量的“平滑程度”来选。
速度分量 u、v 在大部分流场里是连续可导的,用'linear'就够,速度快、不会过冲。'natural'在边界附近的插值更平滑,但代价是构建时间可能翻倍,而且在外凸边界外推时会给出偏大的值。压力场如果有激波或者强梯度,'linear'会出现明显的棱角,这时候可以考虑先加密网格再插值,而不是换成高阶方法硬撑。
'nearest'我基本只在做占位或者验证时用,它在涡核区域会把原本圆滑的分布打成一个个小块,看起来像是数值格式出了问题,很容易误导判断。
% 构建插值对象(只建一次) F.u = scatteredInterpolant(flow.x, flow.y, flow.u, 'linear', 'none'); F.v = scatteredInterpolant(flow.x, flow.y, flow.v, 'linear', 'none'); % 目标网格 nx = 400; ny = 300; xg = linspace(min(flow.x), max(flow.x), nx); yg = linspace(min(flow.y), max(flow.y), ny); [Xq, Yq] = meshgrid(xg, yg); Uq = F.u(Xq, Yq); Vq = F.v(Xq, Yq);注意最后一个参数'none',它控制外插行为。默认是'linear',意思是落在凸包之外的点会沿边界线性外推。这在流场边界附近会造出物理上不存在的值,尤其是流的出口区域,外推出来的速度可能比场内最大值还大,把色标整个拉歪。
用'none'的话,凸包外的点返回NaN,绘图函数会自动留白。留白看起来不完美,但至少是诚实的。我现在的习惯是:只要能接受留白,就用'none'。
实操心得:如果插值后图上出现一块奇怪的色斑,先别怀疑求解器。把原始散点用
scatter画一遍叠上去,看看那块区域是不是本来就没数据点。我遇到过好几次,问题在于数据本身有个空洞,而插值把它“补”成了一个假涡。
3.3 streamline 和 streamslice 的取舍标准
这两个函数都能画流线,但适用场景完全不同,混用会浪费很多时间。
streamline需要你手给种子点。好处是控制力强——你想强调哪个区域就在那里密集撒种。坏处是种子点选得好不好,直接决定图好不好看。我一般的做法是用均匀网格撒种,然后靠stream2拿到顶点坐标,做后续的着色或者筛选。
streamslice自己选种子点,还会自动带箭头,出图速度很快,适合快速看一眼流场结构。但它的种子分布是算法内部定的,遇到复杂回流区时,关键区域的流线可能一根都没有。我通常把它当“预览工具”,确认大致结构之后再转到streamline精修。
% 均匀撒种 + 手动控制流线密度 [sx, sy] = meshgrid(linspace(min(xg), max(xg), 18), ... linspace(min(yg), max(yg), 14)); figure('Position',[100 100 900 620]); hold on; % 底层:速度幅值云图 contourf(Xq, Yq, hypot(Uq,Vq), 40, 'LineColor','none'); colormap(turbo); cb = colorbar; cb.Label.String = '速度幅值 |V| (m/s)'; % 上层:流线 h = streamline(Xq, Yq, Uq, Vq, sx(:), sy(:)); set(h, 'Color', [0.15 0.15 0.15], 'LineWidth', 1.0); axis equal; axis tight; xlabel('x (m)'); ylabel('y (m)');这里的层次安排有讲究:云图在底层、流线在上层、箭头最后叠加。原因是云图面积大、颜色重,画在上面会把流线全盖住;而流线作为结构线,必须清晰可辨。这个顺序如果反了,图会看起来很乱。
还想更进一步的话,可以用stream2拿到每条流线的坐标,再用interp1沿路径采样速度,最后用patch或者分段plot把速度大小编码成颜色。这样流线的颜色本身就携带了速度信息,比单纯的黑线信息密度高得多。
4. 标量场与衍生量:让图说出“涡在哪”
4.1 涡量计算的网格间距陷阱
速度矢量图能看出流向,但看不出旋转结构。真正能定位涡的地方,是涡量场。
涡量的定义很朴素:二维情况下 ωz = ∂v/∂x − ∂u/∂y。MATLAB 有现成的curl(X,Y,U,V),但它有个非常容易被忽略的前提:它假设网格间距是均匀的,且直接用索引差分来近似。也就是说,如果你的网格是非均匀的,或者你传进去的 X、Y 是归一化的,curl算出来的值跟真实物理量差一个常数因子,而且这个因子你还不知道是多少。
我现在的做法是老老实实自己写差分,把 dx、dy 显式算出来。麻烦一点,但数值可信。
% 显式计算涡量,网格非均匀也成立 dx = xg(2) - xg(1); dy = yg(2) - yg(1); % 中心差分,边界用单侧差分 [~, dudy] = gradient(Uq, dy, dx); [dvdx, ~] = gradient(Vq, dy, dx); omega = dvdx - dudy;gradient(F, hx, hy)的第二个参数是 y 方向的间距,第三个是 x 方向的间距,顺序跟直觉相反,写反了算出来的涡量符号全错。这个坑我踩过不止一次,排查方式是找一个已知的刚体旋转场验证:整体旋转的流场,涡量应该处处等于 2 倍角速度,符号取决于旋转方向。拿这个当单元测试,一分钟就能确认代码对不对。
还有一点:涡量是速度的导数,插值误差会被放大。如果你的插值网格比原始数据稀疏,涡量场会非常毛糙。我的做法是插值网格至少跟原始点密度持平,宁可算慢一点,也不要在一张糊掉的涡量图上做判断。
4.2 用 contourf 加流线叠加做“语义图”
单看涡量云图,读者只能看到一堆红蓝斑块。单看流线,读者只能看到走向。两张叠在一起,才能既看到“这里有旋转”,又看到“旋转是往哪个方向转”。
叠加的时候有几个细节决定成败。
色标要用发散型配色,中间值对应白色或者浅色,正负两端用对比色。turbo和jet是顺序型色图,不适合表示有正负的量,会把“零”错位成一个明显的颜色。MATLAB 自带的balance或者自己拼一个红白蓝色图都可以。
色标的上下限要对称,caxis([-m m]),其中m = max(abs(omega(:)))。不对称的话,正涡和负涡看起来强度不同,但实际可能是一样的。
流线要用半透明白色或者深色,具体取决于底色亮度。黑色流线在深蓝底色上几乎看不见,白色流线在浅色区又会消失。我一般的处理是给流线加一层描边效果——先画一条粗的浅色线,再在上面画细的深色线,这样在任何底色上都看得清。
m = max(abs(omega(:))); figure('Position',[100 100 950 640]); contourf(Xq, Yq, omega, linspace(-m, m, 41), 'LineColor','none'); colormap(redblue(256)); % 自定义红白蓝色图 cb = colorbar; cb.Label.String = '涡量 \omega_z (1/s)'; clim([-m m]); hold on; h1 = streamline(Xq, Yq, Uq, Vq, sx(:), sy(:)); set(h1, 'Color', [1 1 1 0.9], 'LineWidth', 2.2); % 描边 h2 = streamline(Xq, Yq, Uq, Vq, sx(:), sy(:)); set(h2, 'Color', [0.1 0.1 0.1], 'LineWidth', 0.9); % 主体 axis equal; axis tight; box on; xlabel('x (m)'); ylabel('y (m)');clim是较新版本 MATLAB 的推荐写法,老版本用caxis。这两个函数作用一样,但如果混着写,在某些版本上会报“函数不存在”的错。查一下自己版本再定。
注意:不要把涡量和速度幅值画在同一张图上用双色标。双色标图看起来信息量大,实际上读者的眼睛没法同时校准两套颜色,绝大多数人只会看其中一套。宁可分成两张图并排,也不要硬塞。
4.3 三维涡识别:Q 判据和 isosurface 的配合
二维看涡量,三维就得换指标了。涡量在三维里是个矢量,用幅值做等值面会把剪切层也一起圈进来,因为纯剪切也有涡量但没旋转。
工程上更常用的是 Q 判据。它的定义是速度梯度张量的旋转部分和应变部分的不平衡:
Q = 0.5 * (||Ω||² − ||S||²)
其中 Ω 是反对称部分(旋转),S 是对称部分(应变)。Q > 0 的地方旋转占主导,是涡核的候选区域。
在 MATLAB 里算这个,需要先求速度梯度张量的九个分量,然后做矩阵运算。如果网格点数在百万级,逐个点算矩阵会慢,用向量化写法会快很多。
% 三维速度梯度(假设 Xq,Yq,Zq 为 ndgrid 生成的规则网格) [dudx, dudy, dudz] = gradient(Uq, dy, dx, dz); [dvdx, dvdy, dvdz] = gradient(Vq, dy, dx, dz); [dwdx, dwdy, dwdz] = gradient(Wq, dy, dx, dz); % 旋转张量模平方 Omega2 = 0.5*((dwdy-dvdz).^2 + (dudz-dwdx).^2 + (dvdx-dudy).^2); % 应变张量模平方 S2 = dudx.^2 + dvdy.^2 + dwdz.^2 ... + 0.5*((dudy+dvdx).^2 + (dudz+dwdx).^2 + (dvdz+dwdy).^2); Q = 0.5*(Omega2 - S2);等值面阈值怎么定,是个经验活。我的做法是先看 Q 的分布直方图,取 95 分位或者 99 分位作为起点,然后根据涡核数量往上调。阈值太低会连成一大片,阈值太高只剩几个点。
qth = prctile(Q(:), 96); fv = isosurface(Xq, Yq, Zq, Q, qth); p = patch(fv); p.FaceColor = [0.85 0.3 0.2]; p.EdgeColor = 'none'; p.FaceAlpha = 0.75; isonormals(Xq, Yq, Zq, Q, p); % 让光照更自然 camlight; lighting gouraud; material dull; axis equal; axis tight; view(3); grid off;isonormals这行很关键,不加的话等值面会呈现出一块块的平面感,看起来像是网格太粗。加上之后法向重新计算,表面会平滑很多。material dull则是去掉高光,避免反光把结构盖住。
5. 三维流场可视化:切片、锥体与流管的组合拳
5.1 slice 的用法和它不适合干什么
三维流场最直接的看法是切片。slice(X,Y,Z,V,xslice,yslice,zslice)可以在指定位置切出平面,一下子就能看到内部结构。
这里有个实用技巧:xslice等参数可以传空数组[]表示不切那个方向。只切一个方向叫正交切片,切三个方向叫三视图,是报告里最常见的呈现方式。
插值方式建议用'cubic',虽然慢,但切片上的色块过渡会平滑很多。'linear'在切面经过涡核时会留下明显的三角面片痕迹。当然如果你数据量特别大,'nearest'也是一种选择,只是别指望它能看出细节。
figure('Position',[100 100 1000 700]); xsl = linspace(min(flow.x), max(flow.x), 4); ysl = linspace(min(flow.y), max(flow.y), 4); zsl = median(flow.z); h = slice(Xq, Yq, Zq, Pq, xsl, ysl, zsl, 'cubic'); set(h, 'EdgeColor','none', 'FaceAlpha', 0.85); colormap(turbo); cb = colorbar; cb.Label.String = '静压 p (Pa)'; axis equal; axis tight; view(-35, 25); box on; xlabel('x (m)'); ylabel('y (m)'); zlabel('z (m)');slice最大的问题是它只展示标量场,看不出方向。所以实践里我会在切片上叠加流线或者锥体图,让方向信息也一起呈现出来。
另一个坑是切片位置太多。切了十来个面之后,图会变成一团彩色的糊状物,什么都看不清。我现在一般控制在 3 到 4 个面,而且优先选择有物理意义的位置——比如涡核所在的平面、或者几何的对称面。
5.2 coneplot 的种子点怎么摆才不糊
coneplot是在三维空间里画锥体箭头,比quiver3的表现力强,但种子点摆不好会立刻糊掉。
核心问题是密度。太密,锥体互相遮挡;太疏,看不出结构。我的经验是在三个方向上各取 6 到 10 个点,总数控制在 200 到 600 之间。超过 800 个锥体,在普通屏幕上基本就是一片毛刺。
种子点的分布也有讲究。均匀网格撒种是最省事的,但在有回流的区域会浪费大量种子。更好的做法是用流线的端点作为种子,或者按速度幅值做加权采样。
% 非均匀撒种:高速区密,低速区疏 n = 600; idx = randsample(numel(flow.x), n, true, hypot(flow.u, flow.v) + 1e-6); cx = flow.x(idx); cy = flow.y(idx); cz = flow.z(idx); figure('Position',[100 100 1000 720]); h = coneplot(Xq, Yq, Zq, Uq, Vq, Wq, cx, cy, cz, 1.6); set(h, 'FaceColor', [0.2 0.4 0.8], 'EdgeColor', 'none'); set(gca, 'DataAspectRatio', [1 1 1]); view(-40, 22); camlight; lighting gouraud; xlabel('x (m)'); ylabel('y (m)'); zlabel('z (m)');coneplot的最后一个参数是缩放系数,不是自动缩放的。这点跟quiver不一样,需要你自己根据坐标范围去试。起始值可以取0.5 * 网格间距 / 最大速度,然后微调。
5.3 流管的美感来自半径编码,不是颜色
想让三维流场图“高级”一点,流管(streamtube)是个好选择。它的优势在于:管径可以编码一个标量,比如速度幅值,这样读者能同时看到路径和强度。
实现路径是:先用stream3生成流线顶点,再用interpstreamspeed沿流线插值出速度,最后交给streamtube。
[sx, sy, sz] = meshgrid(linspace(min(flow.x), max(flow.x), 5), ... linspace(min(flow.y), max(flow.y), 5), ... linspace(min(flow.z), max(flow.z), 5)); verts = stream3(Xq, Yq, Zq, Uq, Vq, Wq, sx(:), sy(:), sz(:)); spd = interpstreamspeed(Xq, Yq, Zq, Uq, Vq, Wq, verts, 0.25); h = streamtube(verts, 0.35); set(h, 'EdgeColor', 'none', 'FaceAlpha', 0.9); colormap(turbo);streamtube的第二个参数是管径,跟坐标单位挂钩。如果你的坐标范围是 0 到 1,管径 0.35 会粗得看不到结构;如果范围是 0 到 1000,0.35 细得像头发丝。所以它必须配合数据的实际尺度来定,不能直接抄。
实操心得:三维图最大的敌人是遮挡。我的通用做法是把透明度调起来(
FaceAlpha在 0.6 到 0.85 之间),再配一个camproj perspective做透视投影。这两个一起用,前后层次会立刻出来。但注意透明度叠太多层会导致颜色失真,超过 4 层重叠就基本看不出原本的颜色了。
6. 动画与出图:做成能直接进报告的样子
6.1 帧动画的录制方式和常见卡顿原因
做非定常流场,动画的说服力远超静态图。MATLAB 里录动画有两条路:VideoWriter出 MP4,或者逐帧存成 GIF。
VideoWriter的质量和文件体积控制更好,适合放报告或者演示。GIF 的好处是能直接贴进文档,坏处是颜色数受限,256 色的调色板会让渐变色图出现明显色带。
无论走哪条路,有几点是共通的。第一,每帧的坐标轴范围和色标范围必须固定。如果不固定,动画里画面会一直缩放跳动,看起来像在抖。第二,帧率不要太高。24 到 30 fps 是人眼舒适区,60 fps 在大多数场景下是浪费,还会让文件体积翻倍。第三,出帧之前先跑一遍并清空,避免第一帧带着上一张图的残留。
v = VideoWriter('flow_anim.mp4', 'MPEG-4'); v.FrameRate = 25; v.Quality = 90; open(v); clim([0 umax]); % 固定色标 xlim([min(xg) max(xg)]); % 固定坐标范围 ylim([min(yg) max(yg)]); for k = 1:numFrames % 此处更新数据,改成 set 对象的 CData,而不是重画 hC.CData = speedField{k}; drawnow; writeVideo(v, getframe(gcf)); end close(v);这里的关键是更新时改CData而不是重新contourf。每帧重画一次会重新计算等值线,速度慢十倍不止,而且色标可能会跟着变。直接改句柄属性的CData,速度快,画面也稳定。
6.2 exportgraphics 和 print 的区别,以及字体字号的硬规定
出图这一步,很多人在saveas和print之间反复横跳。我的建议是能用exportgraphics就用它,它专门为文档输出做了优化,能保留矢量信息、控制分辨率、裁掉多余的留白。
print更老,兼容性好,但对透明度的支持有问题。如果你的图里用了FaceAlpha,print出来的 PNG 可能会把透明区域变成黑块或者白块。exportgraphics就没这个问题。
分辨率方面,投期刊或者做正式报告,300 dpi 是底线,想要更清晰就上 600。但注意分辨率一高,文件体积会暴涨,尤其是有大量矢量元素的图。
字体和字号有个不成文的行业习惯:图里的字号至少要跟正文一致,否则缩放到页面宽度之后会小得看不清。我一般把轴标签设成 11 到 12 磅,色标标签 10 到 11 磅,刻度数字 9 到 10 磅。
set(gca, 'FontName','Helvetica', 'FontSize', 11, 'LineWidth', 0.8); set(findall(gcf,'-property','FontSize'), 'FontSize', 11); exportgraphics(gcf, 'fig_flow.png', 'Resolution', 400, 'BackgroundColor','white');BackgroundColor这个参数值得单独提一句。默认导出是透明背景,在深色主题的文档里看是没问题,但一旦被贴到白色页面上,图里的白色文字或者浅色元素就会消失。强制指定白色背景,可以避免这类事故。
7. 常见问题排查实录
7.1 速度矢量方向反了或者“一片红”
这是最高频的问题,原因通常有三个,按概率排。
第一个是坐标轴方向。很多实验数据的 y 轴是向下的,而 MATLAB 的imagesc和contourf默认 y 轴向上。这种情况下 v 分量需要取反,或者用set(gca,'YDir','reverse')。但这个设置只影响显示,不影响计算,所以如果你后面还要算涡量,取反必须在数据层面做,不能在显示层面做。
第二个是速度分量顺序。有的数据文件把 v 放在第二列,有的放在第三列,还有的把 w 和 v 换了位置。判断方法很简单:画一张 u 和 v 的散点图,如果两者高度相关,说明你可能读重了;如果 v 的量级明显异常,那多半是列序错了。
第三个是单位。速度用 mm/s 但坐标用 m,算出来的涡量会差三个数量级,色标一拉,整张图就是一片红。检查方法是看速度幅值的量级是否符合物理预期,比如空气流场在常温常压下,主流速度通常不会超过几百米每秒。
7.2 流线断掉、密度不均、种子点选不好
流线断掉的原因几乎都是遇到了NaN。插值用的是'none'外插,凸包外的点返回NaN,streamline遇到NaN就停止。这是预期行为,不是 bug。
如果觉得断得太难看,有两个选择。一是把种子点往内缩,避开边界区域;二是换成'linear'外插,接受边界处的误差。我一般选前者,因为后者带来的假值更麻烦。
密度不均的问题,根源是种子点均匀但流场不均匀。在回流区,均匀撒种会导致大量种子挤在同一个涡里,画出几十条几乎重合的线。解决办法是先算速度幅值的梯度,在梯度大的地方少撒种,在涡核附近按涡量峰值撒种。或者更简单粗暴:撒完种之后,把距离小于某个阈值的种子点去掉。
7.3 内存爆掉和渲染卡死的处理顺序
三维可视化最容易出事的就是这两个。我总结的处理顺序是从便宜到贵:
先降采样。用reducevolume或者手动隔点取样,把数据量砍到原来的几分之一。这一步对最终图的影响最小,但收益最大。
再降等值面复杂度。isosurface出来的面片数可能上百万,用reducepatch降到十万级别,肉眼几乎看不出区别。
然后换渲染器。set(gcf,'Renderer','opengl')通常比默认的 painters 快,但如果显卡驱动有问题反而会卡。遇到卡死先试试'painters'看看是不是渲染器的问题。
最后才考虑换机器。绝大多数“卡死”其实不是硬件不够,而是数据量超过必要程度太多。
7.4 常见问题速查表
| 现象 | 最可能原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 箭头缩成一个点 | AutoScale 打开了 | 查quiver返回值属性 | 关掉 AutoScale,手动给 Scale |
| 涡量符号相反 | gradient 参数顺序写反 | 用刚体旋转场做单元测试 | 检查gradient(F,hy,hx)顺序 |
| 流线在边界突然消失 | 插值外插返回 NaN | 查插值对象外插参数 | 种子点内缩或改外插方式 |
| 切片图一片黑 | 色标范围被极端值拉大 | 查clim当前范围 | 用分位数定上下限 |
| 三维图卡顿 | 面片数过多 | numel(p.Faces) | reducepatch降复杂度 |
| 导出图里有黑块 | 用了print且有透明度 | 换exportgraphics试 | 改用exportgraphics |
| 动画抖动 | 色标或坐标范围每帧都变 | 对比首末帧 | 循环外固定clim和xlim |
| 等值面像折纸 | 缺isonormals | 看表面是否有硬边 | 补isonormals调用 |
提示:这张表我建议直接存成脚本注释放在项目根目录。排查问题的时候,从头往下走一遍,八成的问题在前三行就能定位,比从头读代码快得多。
8. 我在实际项目里固定下来的几件事
写到这里,分享几个被反复验证过、已经写进模板脚本的习惯。
绘图脚本永远和数据脚本分离。数据脚本负责读取、清洗、插值,输出一个.mat里装着已经规整好的网格和场量;绘图脚本只读这个 mat,不碰原始数据。这样做的好处是,调图的时候不用每次重新插值,而且插值逻辑改了之后能立刻做 A/B 对比。
所有涉及数量的参数都提取成脚本开头的常量区。网格分辨率、种子点密度、色标分位、字号,全部集中在前 20 行。散落在代码里的魔法数字,三个月后自己都看不懂。
每张图都带一个自解释的标题或者注释行。不是给读者看的,是给半年后的自己看的:这张图用的是哪个算例、哪个时间步、什么插值方式、什么阈值。我现在的习惯是在脚本里写% FIG-03: Q=96% 分位, linear 插值, t=0.42s,导出的文件名也带上这几个关键信息。
最后一点关于取色。不要迷信任何一套万能色图。速度幅值这种单调量用顺序色图(如turbo、parula),涡量这种有正负的用发散色图(红白蓝),Q 判据这种只关心是否超过阈值的用单色高低对比。选色图的逻辑是“量的语义”,不是“哪个好看”。我见过太多用jet画涡量的图,中间那段黄绿色把正负边界完全糊掉了,读者根本分不清哪里是涡核。
如果非要我给一个最省事的起点:先把scatteredInterpolant和统一容器用好,二维用contourf加streamline叠层,三维用slice加isosurface,出图统一走exportgraphics。这四件事做对,八成的流场后处理需求都能覆盖,剩下的复杂度基本都来自数据本身的特殊性,而不是工具链的问题。