共形几何与MATLAB网格参数化:从余切权重到LSCM实战
简介一份面向计算共形几何课程的C项目框架专门用于帮助学生实现网格切割、调和映射等几何算法。资源包内含完整可编译的工程结构涵盖cutgraph、harmonic_map两大核心模块以及HodgeDecomposition.cpp、Viewer.cpp等关键实现文件配套有checker纹理、网格模型等测试数据并集成MeshLib与freeglut库适合图形学或几何处理相关专业的学生对照学习与二次开发。压缩包共373个文件以h头文件为主体兼有cpp源文件、m脚本及少量图像资源整体仅7.69MB目录结构清晰便于快速定位代码。目前已吸引234人学习使用尤其适合正在完成共形几何作业或入门离散曲面参数化的读者参考。通过阅读这套框架可以理解从割图构建到调和映射的完整流水线并借助现成示例快速验证算法效果同时减少环境配置与底层网格处理的工作量。1. 共形几何与MATLAB你的网格参数化为什么总是翻转把三维扫描的网格直接交给纹理贴图程序出来的结果常常让人想摔键盘花纹是歪的、眼睛贴到后脑勺上、局部扭曲得认不出原形。共形几何要解决的就是这件事——在把三维曲面摊平到二维平面时保持每一个小区域的角度关系不被打乱。Conformal-Geometry这个资源就是围绕这个问题的一组MATLAB实现覆盖从网格读入、余切权重构建、离散拉普拉斯算子到LSCM参数化与畸变评估的完整流程。适合正在做几何处理、有限元网格生成或纹理映射的人尤其适合被保角条件绕晕、想直接看代码怎么落地的从业者。2. 离散保角映射的实现基础余切权重与拉普拉斯矩阵2.1 连续共形条件在网格上为什么必须离散化连续曲面上的共形映射满足Cauchy-Riemann方程也就是复平面上解析函数实部与虚部的偏导数互相约束。可三角网格是分片线性的压根不存在连续的偏导数硬套连续公式只会得到一堆无法求解的偏微分方程。所以离散共形映射的思路是用每个三角形上的线性插值去逼近共形能量然后把能量最小化问题转成一个稀疏线性系统。这个逼近过程的核心是余切权重。对一条边 (i,j)它可能同时被两个三角形共享每个三角形在边对面有一个角。权重定义为这两个角余切值之和的一半w_ij 0.5 * (cot(α) cot(β))其中α和β是边 (i,j) 所对的两个角。这条公式看起来简单但它同时承担了两个任务一是作为离散拉普拉斯算子的系数二是保证映射在角度上的保角性质。理解了余切权重后面LSCM的矩阵构造就是顺水推舟的事。手算过两个三角形的余切就会发现锐角三角形权重为正钝角三角形权重可能为负。很多第一次接触的人看到负权重就以为代码写错了其实没写错负权重是钝角网格的正常产物。问题在于负权重数量如果太大说明输入网格质量太差后面求解参数化多半会出问题这个坑到第5章再具体展开。2.2 余切拉普拉斯矩阵的MATLAB实现这里给出一个可直接跑通的最小实现。循环写法在MATLAB里不是最快的但胜在逻辑清楚适合对照公式逐行检查function L cotangent_laplacian(V, F) % V: nv x 3 顶点坐标矩阵 % F: nf x 3 三角形索引矩阵 % 返回值 L: nv x nv 稀疏拉普拉斯矩阵 nv size(V, 1); nf size(F, 1); I zeros(3 * nf, 1); J zeros(3 * nf, 1); W zeros(3 * nf, 1); idx 0; for t 1 : nf ids F(t, :); % 对三角形三个顶点分别处理其对边对应的余切 for e 1 : 3 a ids(e); % 角所在的顶点 b ids(mod(e, 3) 1); % 边的两个端点 c ids(mod(e 1, 3) 1); va V(a, :); vb V(b, :); vc V(c, :); u vb - va; v vc - va; % cot(theta) dot(u,v) / |cross(u,v)| cot_a dot(u, v) / norm(cross(u, v)); % 每对邻接点各存一个非零元 idx idx 1; I(idx) a; J(idx) b; W(idx) 0.5 * cot_a; idx idx 1; I(idx) a; J(idx) c; W(idx) 0.5 * cot_a; end end A sparse(I, J, W, nv, nv); % 权重邻接矩阵 A (A A) / 2; % 对称化防止循环顺序导致不对称 L A - diag(sum(A, 2)); % 拉普拉斯矩阵: L A - D end这段代码的核心逻辑是每个三角形贡献三个角每个角对应一条对边权重在边的两个端点间分配。cot的计算直接用了点积除以叉积模的几何公式避开了显式求角度的反三角函数既省时间又避免角度精度损失。两点参数说明第一sum(A, 2)是对每一行求和再取负号这样保证L的每行和恒等于0这是拉普拉斯矩阵的基本性质后续求解时零空间才有意义第二(A A) / 2这一步不是可选项——三角形遍历顺序可能导致同一对点出现两次非对称写入不合并的话矩阵会有微小不对称求解大型稀疏系统时容易引发收敛异常。在实际工程里几百个三角形的循环开销在MATLAB里可以接受但几万面的大网格建议改为向量化实现。向量化版本通常把三角形线性索引展开用accumarray聚合重复下标逻辑上跟这段代码等价速度能快几十倍。我一般先用循环版本验证正确性再决定要不要优化。2.3 LSCM把保角条件写成可求解的最小二乘拉普拉斯矩阵是调和映射的基础但它只保证映射在能量意义下光滑不保证角度完全不变。LSCMLeast Squares Conformal Maps的做法更直接在每个三角形上构造一个复值能量函数衡量参数化结果偏离共形条件的程度然后对全部三角形求和做最小二乘。离散形式下每个三角形上存在一个复梯度LSCM能量写成E Σ_T area_T^{-1} | (∂u/∂x - ∂v/∂y) i(∂u/∂y ∂v/∂x) |²对这个式子做分片线性展开每个三角形的偏导数都能写成三个顶点uv坐标的线性组合于是一个全局的稀疏矩阵M就产生了问题变成min || M * [u; v] ||²形状上跟普通最小二乘完全一致。这里有个容易被忽略的点这个目标函数存在平移、旋转和缩放三个方向的自由度矩阵M是秩亏的。不固定任何约束求解器会给你一个零解参数化结果全部退化成原点。常规做法是固定两个顶点或者固定全部边界顶点把对应的行从M中移除再把已知值移到等式右侧。固定两个顶点是自由边界的锚点法固定全部边界顶点是圆盘参数化路径。两条路在Conformal-Geometry的工具箱里都对应现成的调用入口具体流程下一章展开。2.4 调和映射与LSCM怎么选对比项调和映射LSCM边界处理边界顶点按弧长固定到凸边界可自由只需锚定两个点角度保持弱仅最小化Dirichlet能量强直接惩罚共形误差面积变形一般较大相对较小求解规模一个标量拉普拉斯方程复矩阵最小二乘适用场景网格质量好、边界简单任意拓扑、边界复杂选型经验是模型边界接近凸多边形、网格质量不错直接用调和映射最省事模型有凹边界、耳朵或凹陷特征LSCM的自由边界能显著降低局部畸变。后面第5章会有一个固定边界参数化翻车的具体案例那个模型换成LSCM之后误差直接下降了一个数量级。3. 在Conformal-Geometry里跑通参数化全流程读入网格、固定边界与纹理可视化3.1 网格读入与预处理大多数共形映射工具包内部使用的是简单格式把OBJ读进来的第一步永远是去重和检测连通性。OBJ文件本身可以有重复顶点、孤立的顶点甚至多个不连通的分片——后者对参数化是致命的因为拉普拉斯矩阵会变成块对角求解时各块之间互不可达。预处理最少要完成两件事合并重复顶点、确认三角形法向朝向一致。function [V, F] read_obj_mesh(filename) % 简易OBJ读取器只处理顶点和面 fid fopen(filename, r); V []; F []; line fgetl(fid); while ischar(line) if startsWith(line, v ) vert sscanf(line(3:end), %f %f %f); V [V; vert]; %#okAGROW elseif startsWith(line, f ) face sscanf(line(3:end), %d %d %d); F [F; face]; %#okAGROW end line fgetl(fid); end fclose(fid); % 非流形边检测: 每条边出现的次数不能超过2 E [F(:,1) F(:,2); F(:,2) F(:,3); F(:,3) F(:,1)]; E sort(E, 2); [~, ~, ic] unique(E, rows); cnt accumarray(ic, 1); if max(cnt) 2 warning(存在非流形边参数化结果不可靠); end end这段代码用sscanf逐行解析顶点和面索引最后用unique和accumarray统计每条边的出现次数用以识别非流形边。OBJ面索引有时带//符号顶点/法向/纹理联合索引sscanf(%d %d %d)只能处理纯数字格式读取报错时先检查OBJ文件是从哪个软件导出的。顶点去重是另一件容易被忽略的事。两套网格模型一套顶点数1.2万另一套顶点数3.4万后者面料只多5000多出来的几乎都是重复顶点。不合并的话拉普拉斯矩阵里会出现完全相同的两行稀疏求解器直接报奇异矩阵错误。合并用unique(V, rows)会丢失原有索引对应关系正确的做法是用unique返回的第三个输出参数做重映射。3.2 固定边界的圆盘参数化流程固定边界参数化是最容易入门的路径把边界顶点按弧长比例平摊到单位圆上然后求解内部顶点的拉普拉斯方程。内部顶点的uv坐标由边界的已知值约束公式是L_inner * U_inner -B * U_boundary其中L_inner是内部顶点对应的拉普拉斯子矩阵B是内部顶点与边界顶点之间的耦合矩阵。这个线性系统正定且稀疏MATLAB直接用反斜杠求解就行。function uv fixed_boundary_param(V, F) % 固定边界圆盘参数化 nv size(V, 1); L cotangent_laplacian(V, F); bnd compute_boundary(F); inner setdiff(1:nv, bnd); % 边界顶点按弧长映射到单位圆 m length(bnd); edge_len vecnorm(V(bnd, :) - V(circshift(bnd, -1), :), 2, 2); arc cumsum(edge_len) / sum(edge_len); theta 2 * pi * arc; uv_bnd [cos(theta), sin(theta)]; Lin L(inner, inner); Bin L(inner, bnd); B -Bin; % 移到右侧 uv_inner Lin \ (B * uv_bnd); uv zeros(nv, 2); uv(bnd, :) uv_bnd; uv(inner, :) uv_inner; end function bnd compute_boundary(F) E [F(:,1) F(:,2); F(:,2) F(:,3); F(:,3) F(:,1)]; E sort(E, 2); [~, ~, ic] unique(E, rows); cnt accumarray(ic, 1); bnd_edge E(cnt 1, :); % 边界点按顺序串起来 bnd bnd_edge(1,:); while ~isempty(bnd_edge) idx find(any(bnd_edge bnd(end), 2), 1); if isempty(idx), break; end next_id setdiff(bnd_edge(idx, :), bnd(end)); bnd(end 1) next_id; %#okAGROW bnd_edge(idx, :) []; end end这里有两个关键点。第一arc cumsum / sum是弧长归一化它保证边界点在圆上的间隔与原始网格边的长度成正比避免出现“原始模型上稀疏的边在参数化后挤在一起”。第二求解是分块进行的Lin \ B直接对右端矩阵求解一次性得到所有内部顶点的两个坐标列。如果compute_boundary里的边界串联循环没收敛多半是网格存在多个边界环。简单模型只有一个外边界带孔洞的模型会有多个环这时需要按环分别处理。Conformal-Geometry包的示例里绝大多数是单连通模型遇到孔的模型建议走下一节的自由边界LSCM。3.3 自由边界LSCM只固定两个点自由边界的意思是让参数化的外边界在二维平面上自己找位置不被圆盘约束。好处是边界附近的畸变大幅下降坏处是求解目标多了一个自由度必须手动锚定至少两个点消除平移和旋转不变性。实现上构造LSCM的复能量矩阵M之后把两个锚点对应的行删掉UV值设成固定常数。MATLAB里用lsqminnorm比直接写正规方程更稳定代价是内存开销略大。核心代码框架function uv lscm_param(V, F, anchor_idx) % anchor_idx: 2x1 锚点索引例如 [1; 50] A build_lscm_matrix(V, F); % 稀疏复矩阵 nv size(V, 1); free setdiff(1:nv, anchor_idx); % 锚点固定到 (0,0) 和 (1,0) fixed_vals [0 0; 1 0]; % 方程两边同时处理锚点 rhs -A(:, anchor_idx) * (fixed_vals(:,1) 1i * fixed_vals(:,2)); M A(:, free); z_free lsqminnorm(M, rhs); uv zeros(nv, 2); uv(anchor_idx, :) fixed_vals; uv(free, :) [real(z_free), imag(z_free)]; endbuild_lscm_matrix的内部逻辑就是2.3节讲的每个三角形展平到局部坐标系构造复梯度算子输出一个大而稀疏的复矩阵。锚点选在模型的极值位置比如头顶和下巴而不是任意两点锚点如果太接近数值上容易把整个映射拉歪。自由边界和固定边界双路径都跑通之后参数化结果的可视化就成了下一个刚需。看起来是单独一步实际操作时它是排查参数化是否翻转的最直观手段。3.4 纹理可视化用uv坐标检查映射质量把uv坐标映射到一张图片上再把图片贴回三维网格任何角度畸变和翻转在视觉上都会暴露无遗。MATLAB里用trisurf配合纹理坐标可视化即可function visualize_texture(V, F, uv, texture_img) % texture_img: HxWxC 纹理图 figure; % 用顶点uv作为纹理坐标 trisurf(F, V(:,1), V(:,2), V(:,3), ... FaceColor, texturemap, ... CData, texture_img, ... FaceVertexCData, uv); axis equal; shading interp; colormap gray; endFaceVertexCData传入的uv坐标需要归一化到纹理图像素的索引范围具体说就是把uv最大最小值映射到[1, size(tex,1)]区间。这点最容易出错的是方向问题——纹理坐标的v轴在图像里是从上到下增大而参数化默认从下到上增大不做翻转的话贴出来的纹理会倒立。可视化这一步还有个额外好处能直接看到三角形是否翻转。如果某个三角形贴图颜色方向跟邻居相反说明它的uv坐标发生了局部翻转。这种视觉异常比任何数值指标都来得直观。4. 参数化质量的量化评估角度畸变、面积畸变与热力图4.1 角度畸变的计算口径参数化是否有用的最终判据是畸变程度。角度畸变指的是原始三维网格上某个三角形的内角与参数化后对应三角形的内角之差。对一个三角形取三个角的平均偏差就能得到该处的局部角度保持程度。标准做法是逐一三角形计算δ_angle (1/3) * Σ_k | angle_3D_k - angle_2D_k |角度用反余弦求单位是度。一般误差在5度以内属于良好映射超过15度肉眼就能看出纹理歪斜。实际使用时不要只看全局平均——平均值漂亮但可能有少数三角形局部误差30度。我习惯同时输出最大误差和误差分布直方图。function angle_err per_triangle_angle_error(V, F, uv) nf size(F, 1); angle_err zeros(nf, 1); for t 1 : nf i F(t,1); j F(t,2); k F(t,3); a3 angle_between(V(i,:), V(j,:), V(k,:)); a2 angle_between(uv(i,:), uv(j,:), uv(k,:)); angle_err(t) mean(abs(a3 - a2)); end end function ang angle_between(p1, p2, p3) % 顶点p1处由p2和p3张成的角 u p2 - p1; v p3 - p1; ang acosd(dot(u, v) / (norm(u) * norm(v) eps)); end这个函数直接用三维坐标和二维uv坐标算三个角逐一做差取平均。注意acosd的输入要加eps防除零网格里存在退化三角形面积接近0时这行代码就是后悔药。4.2 面积畸变与雅可比行列式角度保持之外还有面积保持这个维度。共形映射本身不保证面积不变但面积畸变过大说明局部严重拉伸或压缩。面积畸变用参数化前后三角形面积之比的常用对数表示δ_area log(area_2D / area_3D)取对数让大于1的拉伸和小于1的压缩对称显示。面积比0.5和2在视觉上畸变程度其实一样取对数之后一个是-0.3一个是0.3分布图更直观。function area_err per_triangle_area_error(V, F, uv) nf size(F, 1); area_err zeros(nf, 1); for t 1 : nf i F(t,1); j F(t,2); k F(t,3); a3 norm(cross(V(j,:)-V(i,:), V(k,:)-V(i,:))) / 2; a2 abs(det([uv(j,:)-uv(i,:); uv(k,:)-uv(i,:)])) / 2; area_err(t) log(a2 / (a3 eps)); end enddet直接给出二维三角形的带符号面积取绝对值避免三角形方向影响结果。算完面积误差后画热力图的核心是trisurf的CData参数把area_err传进去颜色从蓝到红表示压缩到拉伸。4.3 综合可视化一张图画完角度和面积畸变把两个误差合并到同一张图上用左右两个子图展示是排查参数化质量的最快方式。左侧画角度误差右侧画面积误差颜色范围用对称或统一映射方便对比。function plot_distortion(V, F, uv) angle_err per_triangle_angle_error(V, F, uv); area_err per_triangle_area_error(V, F, uv); subplot(1, 2, 1); trisurf(F, V(:,1), V(:,2), V(:,3), angle_err); title(Angle Error (deg)); colorbar; axis equal; shading interp; clim([0 15]); subplot(1, 2, 2); trisurf(F, V(:,1), V(:,2), V(:,3), area_err); title(Area Error (log ratio)); colorbar; axis equal; shading interp; clim([-1 1]); endclim范围不要自动——角度误差固定到0到15度面积误差固定到-1到1这样不同模型之间才能横向对比。每次调完参数化参数我第一件事就是跑这个函数看颜色是不是均匀分布的如果某个区域的红色特别刺眼那部分基本就是后续处理要重点加密或重新网格化的区域。5. 避坑与常见问题翻车、钝角网格与求解失败5.1 参数化结果整体翻转或局部翻转现象纹理贴图后局部区域颜色方向与周边相反或者三角形uv坐标面积出现负值。原因输入网格的三角形法向不一致部分三角形被反转LSCM求解时锚点选择不合理导致整体镜像翻转。解决先统一三角形朝向。对每个三角形计算法向与网格质心的相对方向把所有三角形调整成同一手性。锚点尽量选在模型几何极端位置且连线穿过模型中心。翻转正确后uv面积应全部为正检查方式就是在det计算里加符号判断。5.2 余切权重负值过多现象拉普拉斯矩阵对角元出现大量非正数求解器发出警告或不收敛。原因输入网格含大量钝角三角形。钝角处的余切天然为负这是几何事实不是代码bug。解决负权重的存在本身不影响拉普拉斯求解但若负权重占比超过20%说明网格质量太差参数化结果必然畸变严重。先用各向同性重网格化或者让钝角三角形数量下降再跑参数化省下的时间是成倍的。5.3 孤立顶点导致奇异矩阵现象求解Lin \ b报“Matrix is singular”错误。原因OBJ文件里存在不参与任何三角形的孤立顶点或者网格分成多个不连通分片。这些顶点在L矩阵里对应全零行。解决在预处理阶段删掉所有不在F中出现的顶点索引同时把F重新编号。如果网格是多分片只能每个分片单独参数化最后再拼合纹理坐标。5.4 边界点串联无限循环现象compute_boundary函数跑不完或者输出的边界点顺序杂乱。原因网格边界存在分叉点——一个边界点连接三条以上边界边边界环不是一条简单闭合链。解决出现这种情况必须先修复网格拓扑不能强行参数化。半edge数据结构查一遍流形度非流形边删除或修改后重新检查。5.5 固定边界映射到圆盘后畸变集中在边界现象热力图显示边界附近误差远大于内部区域纹理在模型边缘拉伸严重。原因把边界硬性固定到圆盘相当于对边界顶点施加了人为约束。圆盘的曲率和原始边界的曲率不匹配能量集中在边界附近释放。解决换成自由边界LSCM。原始模型边界如果本来就接近圆形固定边界问题不大模型有尖锐凸起自由边界几乎是唯一选择。6. 进阶验证技巧保角误差热力图与迭代式调参参数化做完之后先别急着导出结果用保角误差热力图做一轮系统验证能省掉后面整条生产流程的返工成本。具体操作是把某一类畸变指标按三角形画到原始三维模型表面上同时叠加边缘线和稀疏采样点形成一张可旋转检查的调试视图。一个值得养成的习惯是先在低分辨率网格上跑通全流程确认参数化稳定再对原始高精度网格执行同一套参数。低分辨率网格的拉普拉斯矩阵规模小一个数量级求解时间从分钟级降到秒级迭代调参的效率完全不一样。我的常规流程是——先用固定边界调和映射跑一次画角度畸变热力图如果边界附近的红色区域范围超过总面积的10%切换自由边界LSCM再跑对比两次结果的全局最大误差取优者进入后续纹理映射管线。有过一次印象深刻的翻车经历某次给一个带耳朵的模型做纹理贴图图省事直接固定边界参数化结果耳朵根部角度误差高达22度贴上纹理后五官全部扭曲。换成自由边界LSCM并增加锚点之后最大误差降到4.8度整个模型的视觉效果天差地别。从那以后我每次拿到新模型都强制走一遍“低分辨率试参、热力图验证、双方案对比”这个流程参数化从来没再出过大问题。希望帮到你——把这里的流程跑通你手里的模型离干净纹理只差一次热力图检查的距离。本文还有配套的精品资源点击获取