简介:这份 MATLAB/Octave 脚本提供 Trulle-Krul 拓扑优化方法的开源实现,面向从事各向异性网格自适应、拓扑优化和有限元仿真的研究者与工程师。方法兼顾二维与三维场景,可求解最优导热问题,也支持线性弹性结构的最小柔度优化;代码内置可运行示例,调用参数即可复现 WCSMO12 会议论文中的典型算例,方便读者从理论模型对照实际结果,也能快速试验不同网格尺寸和各向异性参数。压缩包内包含 5 个文件,核心是 3 个 m 格式脚本,另有 md 与 rst 文档负责使用说明和许可证信息,整体约 173KB,结构简洁适合直接上手。目前已有 323 人学习下载;借助脚本注释、示例和文档,读者可以梳理各向异性网格自适应、拓扑优化与有限元求解的衔接流程,并在此框架上继续扩展自己的优化问题。这些代码既可用于科研复现,也可用于教学演示和工程预研。
1. 一套 MATLAB/Octave 脚本,把拓扑优化的网格难题一次性接住
做拓扑优化的朋友大概率都撞过一面墙:同样的边界条件,网格稍微加密一下,得到的传力路径就变了。这不是算法写错了,而是网格在“说谎”——要么单元太粗读不出应力梯度,要么棋盘格把中间密度卡在半吊子上。Trulle-Krul 是一套用 MATLAB/Octave 写的开源脚本,把各向异性网格自适应、SIMP 拓扑优化和 FEM 求解打包到一个系统里,误差估计驱动网格重构,单元沿应力梯度方向拉长,兼顾精度与计算量。它适合给正在写或改 SIMP 代码的从业者做对照实现:先在自带算例上把流程跑通,再替换进自己的求解器。这篇笔记按“原理→跑通→改参→避坑”的顺序把它拆开讲。
2. Trulle-Krul 的代码骨架:三件事为什么要装进一个包
2.1 为什么把网格自适应、拓扑优化、FEM 放在一起
固定网格上的拓扑优化有两个老毛病:网格依赖和棋盘格。网格依赖的意思是,同样一个悬臂梁问题,用 60×30 单元和 120×60 单元跑出来的最优传力路径可能差出好几根杆件;棋盘格则是密度场出现高频交替分布,看着像噪声,实际上是 FEM 在粗网格下对某些模态过度刚化导致的数值伪影。SIMP 方法靠过滤半径压棋盘格,但过滤半径和网格密度之间的耦合关系很敏感,稍微改动就要重新调参。
Trulle-Krul 的做法是把网格当作优化循环里一个“会呼吸”的变量:每跑若干步,用当前 FEM 解做后验误差估计,生成各向异性度量张量,再让网格生成器按这个张量重构单元。应力梯度大的方向加密边长,平缓的方向拉长单元,整体单元数不暴涨,局部精度却比均匀加密高一截。这套思路单独实现网格自适应不难,单独实现 SIMP 也不难,难的是三者之间的数据交换:节点编号变了、单元拓扑变了、密度场怎么搬过去、边界条件怎么重新定位。
这也是 Trulle-Krul 作为一个整体存在的理由。它把“求解→误差估计→网格重构→解映射→再求解”的闭环藏在模块化脚本里,你只需要关心每个模块的输入输出。我在拆这份资源时最看重的就是它的结构感:不是把一堆函数扔进一个文件夹就完事,而是有清晰的求解器、网格、度量估计、后处理分层。对于想快速验证自己算法的从业者来说,这种分层比单点技巧更有参考价值。
2.2 代码结构与核心模块划分
把压缩包解压后,目录大致分成四块:求解器模块、网格模块、度量与误差模块、Examples 算例目录。求解器模块里既有 SIMP 拓扑优化的主循环,也有独立的线性弹性 FEM 求解函数,后者可以被单独拿出来替换成瞬态或热弹性的求解器。网格模块则封装了与 Bamg 的交互:Bamg 是开源的各向异性网格生成器,Trulle-Krul 通过调用 Bamg 完成度量场到新网格的转换。
下表是拆包时整理出的关键部分,按实际项目里的角色划分,不保证和所有版本逐字一致,但定位一致:
| 模块 | 职责 | 关键脚本(示意) |
|---|---|---|
| FEM 求解 | 组装刚度矩阵、施加边界条件、解线性方程组 | trkr_fem.m / trkr_solve.m |
| SIMP 拓扑优化 | 密度初始化、灵敏度计算、OC 更新、过滤 | trkr_simp_main.m / trkr_sens.m |
| 误差估计 | 恢复梯度法、单元误差指示子 | trkr_estimat.m |
| 度量生成 | 由误差张量构造成本度量矩阵、约束各向异性比 | trkr_metric.m |
| 网格重构 | 调 Bamg 执行自适应网格生成 | trkr_adaptmesh.m |
| 后处理 | 密度云图、柔量曲线、网格质量输出 | trkr_plot.m |
这套系统开源项目的特点就是没有黑匣子。Bamg 本身是用 C++ 写的编译型工具,MATLAB/Octave 脚本只是负责生成参数文件、调用可执行文件、读回网格数据。你如果对网格生成算法本身感兴趣,可以直接去读 Bamg 的源码;如果只关心拓扑优化流程,那只看 MATLAB 脚本就够了。
2.3 核心算法原理:各向异性度量张量与 SIMP 灵敏度
各向异性网格自适应的核心不是一个网格生成器,而是“如何生成度量张量”。简单说,每个节点或每个单元上定义一个 2×2 对称正定矩阵 M,它描述了该位置理想的单元边长分布:沿某方向需要的边长越短,M 在该方向的“权重”越大。Bamg 拿到 M 之后,重构出的单元会沿误差梯度最大的方向变细,沿误差平缓的方向变宽,全局单元数量因此得到控制。
误差估计用的是恢复梯度法:先用当前 FEM 位移结果算出单元内的应力梯度,再把节点上的梯度做一个超收敛恢复,恢复值与原始值之差就是误差指示子。误差大的地方,度量张量对应方向的长度分量减小;误差小的地方放大长度分量,并用 hmin 和 hmax 兜底,避免网格无限加密或稀疏。这个“先算误差、再构造 M、最后让网格生成器去拟合 M”的流程,是整份资源里含金量最高的一段逻辑。
SIMP 这边的灵敏度计算走的是伴随法标准路线:目标函数是最小化柔量 c = UᵀKU,约束是总体积分数不超过给定值。单元密度 x_e 通过插值公式 E(x_e) = E_min + x_e^p(E_0 − E_min) 影响材料刚度,灵敏度表达式最终化简成与单元应变能相关的形式。Trulle-Krul 把这一串公式拆成了两个函数:一个负责 FEM 求解得到位移,一个负责回代灵敏度,外层由 OC(优化准则法)更新密度。理解这个拆法之后,你替换自己的 MMA 求解器也只是改外层那一小段。
3. 跑通第一个 SIMP 算例:初始化、参数表、柔量曲线解读
3.1 获取源码与初始化运行环境
把下载到的源码包解压到工作目录后,第一步不是直接跑算例,而是把路径和编译环境理顺。Trulle-Krul 的脚本大量依赖私有函数,文件夹结构一变就容易出现函数找不到,所以我一般先把整个项目目录加进 MATLAB 搜索路径,然后跑一次自带初始化脚本。
我习惯的做法是写一个独立的环境配置脚本,内容大致长这样:
% init_trkr.m % 把整个项目目录及其所有子目录加入搜索路径 project_root = fileparts(mfilename('fullpath')); addpath(genpath(project_root)); % 检查 Bamg 可执行文件是否在当前环境 if isempty(getenv('BAMG_PATH')) setenv('BAMG_PATH', fullfile(project_root, 'bin', 'bamg')); end % 记录环境配置状态 disp('Trulle-Krul environment ready.');这段代码的逻辑很简单:用genpath把所有子目录一次性加入搜索路径,避免漏掉某个模块;然后检查环境变量BAMG_PATH,如果没设置,就默认指向解压目录里的 bin 文件夹。这个环境变量在整个自适应流程里非常关键,网格生成器找不到时,报错信息会非常隐蔽,后面避坑章节会细说。
如果用的是 Octave,路径设置的命令完全一样。Octave 下第一次使用可能还需要编译 mex 文件,常见做法是在终端执行一次mkoctfile --mex相关的构建指令。这一步我在避坑章节会专门讲,这里先记住一个结论:MATLAB 用户一般开箱即用,Octave 用户大概率需要先处理编译器。
3.2 悬臂梁算例:脚本逐段拆解
路径配好之后,就可以把经典悬臂梁拓扑优化算例跑起来了。我拆包之后把主循环抽出来重写了一遍,逻辑与包内原版保持一致,但去掉了算例特有的后处理细节,方便你对照理解:
% simple_simp_cantilever.m % 悬臂梁尺寸:80x40 单元,左端固定,右端中点施加向下单位力 nelx = 80; nely = 40; % 水平与垂直方向的单元数 volfrac = 0.35; % 体积分数约束:优化后材料占比 35% penal = 3.0; % SIMP 惩罚指数,常用 3 rmin = 1.6; % 密度过滤半径,单位是网格边长倍 % 左端固定所有节点的两个自由度 fixed_dofs = unique([...]); % 实际代码里由节点坐标生成 % 右端中点施加向下载荷,构造稀疏力向量 F = sparse(2 * (nelx + 1) * (nely + 1), 1); F(2 * (nelx + 1) * (nely + 1) - (nelx + 1) + 1, 1) = -1.0; % 设计变量初始化:所有单元均匀铺满密度 volfrac x = repmat(volfrac, nely, nelx); % SIMP 主循环 for iter = 1:200 % 1) 求解有限元:返回位移场 U 和全局刚度矩阵 K [U, K] = trkr_fem(x, penal); % 2) 计算柔量目标函数 c(iter) = 0.5 * U' * K * U; % 3) 伴随法灵敏度 dc = trkr_sens(U, K, x, penal); % 4) OC 更新设计变量 x = trkr_oc(x, volfrac, dc, rmin); % 5) 每 25 步画一次密度云图和收敛曲线 if mod(iter, 25) == 0 trkr_plot(x, c); end end这段代码里的trkr_fem返回的是给定密度场下的位移和全局刚度矩阵,trkr_sens完成灵敏度回代,trkr_oc是优化准则法更新器。之所以把三个模块拆开写,是为了让你在替换自己的 MMA 求解器或不等向性材料模型时,只动第四个调用,不必重写 FEM 部分。
penal取 3.0 是 SIMP 的默认经验值,惩罚力度不足会出现中间密度;rmin取 1.6 到 2.0 之间可以稳定压制棋盘格,但不能取太大,否则会把真实传力路径的细杆结构抹掉。volfrac从 0.3 到 0.5 都是悬臂梁算例的常见区间,低于 0.3 时结构会变得很纤细,对网格质量要求更高。
3.3 参数表与结果解读
跑完 200 次迭代后,看柔量曲线比看最终密度图更有诊断价值。健康的收敛曲线是前几十步大幅下降,然后斜率逐渐放缓,末尾趋于水平;如果曲线在收敛后再次突然上升,说明密度场出现了振荡,通常是过滤半径太小或 OC 更新的步长限制太松。
| 参数 | 含义 | 典型范围 | 调节方向 |
|---|---|---|---|
| penal | SIMP 惩罚指数 | 2.5~4.0 | 太小出灰度,太大会过早锁死局部结构 |
| rmin | 过滤半径(网格边长倍数) | 1.2~2.5 | 越小细节越丰富,越小棋盘格越严重 |
| volfrac | 体积分数上限 | 0.25~0.5 | 越小结构越稀疏,越依赖网格自适应 |
| move_limit | OC 更新步长上限 | 0.15~0.3 | 太小收敛慢,太大收敛震荡 |
| max_iter | 最大迭代步数 | 150~400 | 复杂结构用 300 步以上,简单梁 150 步足够 |
这个算例跑通之后,你对这套资源的核心手感就建立起来了。接下来要做的不是调参刷更低柔量,而是把分布式的网格自适应能力激活,因为它会把“网格加密到什么程度”这个选择权从你的手里交给误差估计器。
4. 各向异性网格自适应怎么做:误差估计、度量张量与节点插值
4.1 恢复梯度法怎么找出“哪里该加密”
均匀加密网格是最贵的方式,因为它照顾不了局部。各向异性自适应的第一步,是从当前 FEM 解里找一个可靠的误差指示子。Trulle-Krul 实现的恢复梯度法,核心思想是把单元内的非连续应力场投影到节点上做光滑化,然后把光滑后的梯度与原始梯度做差。
这一步的输出是一个逐单元误差估计值 eta_e,它告诉你哪个单元的应力梯度没有被当前网格分辨率捕获。梯度变化剧烈的单元,误差指示子大,就是后续加密的候选者;梯度平缓的区域,误差指示子小,对应的单元会被拉长而不是加密。这种“不同方向不同边长”的精细操作,比单纯把所有高误差区域一起缩小边长要高效得多,单元总数涨幅通常控制在 30% 以内,就能让应力场的精度成倍提升。
4.2 度量张量三兄弟:hmin、hmax、anis
误差指示子只是告诉你“哪里要改”,具体改成什么形状还得靠度量张量 M 来量化。M 在二维情况下是一个 2×2 对称正定矩阵,它的特征向量指示了单元应拉长和应压扁的方向,特征值则对应两个方向上的理想边长倒数。实际构造 M 时,有三个参数直接决定网格长相。
hmin 是全局最小边长,任何方向上的单元边长都不会低于它,防止误差集中区域产生无限加密的奇异单元;hmax 是全局最大边长,防止远场区域出现过于粗糙的单元;anis 是各向异性比上限,也就是最长边与最短边的比值上限。anis 取 1 时退化为各向同性自适应;取 5 到 10 时单元明显拉长,适合处理应力集中问题;取超过 20 时容易生成畸变单元,FEM 求解精度反而下降。
| 参数 | 作用 | 典型区间 | 踩过的坑 |
|---|---|---|---|
| hmin | 最小边长下限 | 0.001~0.01 | 太小导致节点数爆炸 |
| hmax | 最大边长上限 | 0.1~0.5 | 太大导致远场单元过度稀疏 |
| anis | 最长边/最短边比值上限 | 5~15 | 超过 20 后单元畸形率上升 |
4.3 自适应闭环代码与密度插值
完整跑一轮自适应需要四步:FEM 求解、误差估计、度量构造、网格重构。如果拓扑优化还在进行,网格变化后还要把旧网格上的密度场搬到新网格上。我写的自适应闭环大致长这样:
% adapt_cycle.m % 输入:当前网格、位移场 U、控制参数 % 输出:重构后的网格与误差估计 function [nodes_new, elems_new, eta] = adapt_cycle(nodes, elems, U, param) % 1) 误差估计:返回逐单元误差向量 eta eta = trkr_estimat(nodes, elems, U); % 2) 构造度量张量 M(hmin / hmax / anis 控制单元形状) M = trkr_metric(eta, param); % 3) 调用 Bamg,按度量场重构网格 [nodes_new, elems_new] = trkr_adaptmesh(nodes, elems, M); end这段代码的逻辑不算复杂,但有个容易翻车的点:trkr_metric输出的 M 是定义在节点还是单元上的,必须和trkr_adaptmesh的输入约定一致。如果 M 定义在单元上而网格生成器期望节点上的值,就会得到完全错乱的网格。我拆包时第一眼没注意这个,跑出来的网格像被风吹散了一样,最后是逐行对照两个函数的文档才定位到问题。
网格一变,设计变量也要重新定位。旧网格上的密度值不能直接赋给新网格单元,因为节点编号和单元拓扑完全变了。常见做法是保留旧网格的节点坐标和密度值作为散点数据,用插值把密度映射到新网格单元中心:
% density_transfer.m % 旧网格密度 x_old -> 新网格密度 x_new % 先取旧网格每个单元中心坐标,再构造插值函数 center_old = (nodes_old(elems_old(:,1), :) + nodes_old(elems_old(:,2), :)) / 2; F_interp = scatteredInterpolant(center_old(:,1), center_old(:,2), x_old, 'linear', 'nearest'); % 对新网格单元中心采样 center_new = (nodes_new(elems_new(:,1), :) + nodes_new(elems_new(:,2), :)) / 2; x_new = F_interp(center_new(:,1), center_new(:,2));这段代码的关键注释是'nearest'这个外推方法:自适应后的网格可能在边界处略微超出旧网格范围,如果不指定外推方式,MATLAB 会返回 NaN,整个灵敏度计算直接崩掉。用最近邻外推虽然会在边界处产生一点锯齿,但至少能保证拓扑优化主循环稳定往下走。如果你希望边界更光滑,可以在插值前把离散节点坐标稍微内缩一点,这个技巧对结果影响不大,但能省掉不少调试时间。
5. 避坑合集:五条能帮你省一整天的实战经验
5.1 Octave 下 mex 编译失败
现象:在 Octave 里跑初始化脚本后,调用某个核心函数时报错“mex: command not found”或“undefined symbol”,但 MATLAB 下同样代码却能正常运行。
原因:Trulle-Krul 的某些底层模块依赖 mex 文件,MATLAB 自带编译器配置向导,Octave 则需要手动安装开发包和 C++ 编译器,缺一不可。很多 Octave 发行版默认不带 mex 编译链,只装了纯解析器。
解决:Ubuntu 系先安装octave-dev和g++,然后在 Octave 终端执行mkoctfile --mex -v验证编译器是否识别。如果依然失败,检查是否用了 32 位 Octave 和 64 位编译器混搭的情况,这个坑在旧版本 Octave 里尤其常见。从那以后我每次在 Octave 下动手之前,都会先跑一遍这一条确认命令。
5.2 Bamg 外部程序找不到或没有执行权限
现象:自适应函数报错“Unable to execute bamg”或者“bamg: command not found”,但脚本里明明已经指定了文件路径。
原因:Bamg 是独立编译的二进制程序,不是 MATLAB 函数。解压后如果没有执行权限,或者环境变量BAMG_PATH指向的位置不对,脚本即使在 MATLAB 里能找到文件,操作系统层面的调用依然会失败。Windows 上还要额外注意路径分隔符。
解决:先确认 bin 目录下文件确实存在,然后在 MATLAB 外手动执行一次确认它有运行权限。Linux/macOS 执行chmod +x bamg,Windows 检查是否缺少运行时依赖库。设置环境变量时用绝对路径,不要用相对路径,因为脚本运行时的工作目录经常和项目根目录不一致。
5.3 目标函数震荡,柔量曲线上下跳动
现象:SIMP 循环跑到一半,柔量曲线不单调下降,在某个值附近反复震荡,密度云图出现棋盘格纹理。
原因:过滤半径 rmin 设置相对网格太小,或者 OC 更新的步长上限 move_limit 设得太大。两者都会让密度场在相邻两次迭代之间出现大幅跳变,相当于优化过程在能量面上“蹦”而不是“走”。
解决:把 rmin 提升到 1.5 以上,同时把 move_limit 压到 0.2 附近。如果震荡仍然存在,检查灵敏度过滤是否和密度过滤同时开启——Trulle-Krul 里这两个过滤是独立参数,我见过只过滤密度不过滤灵敏度的情况,棋盘格就是滤不掉。
5.4 最终密度场灰度严重,大量单元卡在中间值
现象:迭代收敛后,密度云图不是黑白分明的结构,而是大片 0.3~0.7 的灰色过渡区,看起来像雾蒙蒙的一片。
原因:SIMP 惩罚指数 penal 偏低,中间密度受到的惩罚不够,优化器没有动力把它们推向 0 或 1。另一种情况是迭代终止过早,灰度区还没来得及被挤出。
解决:把 penal 从 3.0 调到 3.5~4.0,增设“后处理阈值化”步骤:收敛后把密度大于 0.5 的单元强制置 1,小于 0.5 的置 0,再用这个 0/1 密度做两轮 FEM 验证,确认柔量变化在可接受范围。
5.5 自适应后刚度矩阵奇异,求解器报错
现象:加入网格自适应后,FEM 求解偶尔报“Matrix is singular”或位移场局部出现异常尖峰。
原因:自适应网格重构后,部分单元严重畸形,比如出现负面积或接近零的 Jacobian;另外,旧的边界条件索引是基于旧节点编号记录的,网格一变,索引指向的节点就不在原位置了,边界条件实际被错误地施加到了内部节点上。
解决:在自适应之后加一道单元质量检查,计算每个单元的 Jacobian 行列式,如果出现负值就做局部重建。边界条件不按节点索引记录,而是按几何坐标重新定位:算例里的固定端和载荷点都有明确坐标,网格重构后按最近节点重新绑定。从那以后我每次做自适应都会强制走一遍“坐标定位边界”的流程,翻车概率直接降了一个量级。
6. 把自适应嵌进 SIMP 主循环:一个杂活很多的进阶框架
6.1 嵌套循环与收敛判据
网格自适应不是迭代越快越频繁就越好。每一步都重构网格,密度场插值、边界重定位、度量计算全都在反复折腾,计算量浪费严重;而且密度场还没稳定时,误差估计本身就不可靠,重构出来的网格反而会把优化方向带偏。我实践下来的结论是:每 20~30 步触发一次自适应,外层总迭代 200 步时做 6 到 8 次网格更新足够了。
嵌套循环的收敛判据要分成两层看:内层看 SIMP 密度变化量,外层看网格重构前后的柔量差异。外层判据的标准做法是记录每次自适应后第一个完整 SIMP 循环的柔量,如果两次相邻自适应之间的柔量相对变化小于 1%,说明网格分辨率已经接近最优,可以停止再重构,让当前网格把密度场磨到最后收敛。
% 外层网格自适应触发框架(示意) if mod(iter, 25) == 0 [nodes, elems] = adapt_cycle(nodes, elems, U, param); x = density_transfer(nodes_old, elems_old, x_old, nodes, elems); % 重新定位边界条件 boundary = relocate_boundary(nodes, geo); end这段示意代码里的relocate_boundary是按几何坐标重新绑定边界条件的函数,它不是 Trulle-Krul 自带的,但每个涉及网格重构的项目几乎都要补这么一层。中间如果漏掉密度插值或边界重定位,整个循环就像链子断了一环,跑出来的结果根本不是自适应网格上的真实最优解。
6.2 网格质量三查:负 Jacobian、插值一致性、边界节点漂移
每次自适应完成后的验收,我会强制检查三件事。第一件是负 Jacobian,用单元坐标直接算每个单元的面积,出现负值立刻回滚到上一轮网格,避免脏数据污染后续迭代。第二件是插值一致性:把旧密度场插值到新网格后,再反向插回旧网格,看偏差是否控制在 5% 以内,这个检查能暴露插值方法选错的问题。第三件是边界节点漂移:检查固定端的约束节点在重构后是否偏离了原始几何边界超过一个容差,超了就按最近的边界点修正。
这套三查流程每次跑自适应都要走一遍。之前有次我贪快,跳过反向插值检查,结果密度场在两次自适应之间损失了大量细节,最终结构少了一根关键的斜撑,追了整整一天才定位到插值方向搞反了。从那以后我每次跑自适应都强制先做一轮三查再进主循环,算是用踩坑换来的习惯。
Trulle-Krul 这套资源能帮你把拓扑优化、各向异性网格自适应和 FEM 求解串成一条完整的链,但它不会替你检查边界条件和网格质量——这些杂活永远得自己把关。希望帮到你。
本文还有配套的精品资源,点击获取