news 2026/10/26 18:42:01

三角网格保角参数化实战:余切拉普拉斯、LSCM与Ricci流

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
三角网格保角参数化实战:余切拉普拉斯、LSCM与Ricci流

简介:面向计算共形几何课程作业与几何算法实践,这份项目框架适合高校学生和开发者搭建可编译运行的C++实验环境,重点覆盖割图、调和映射等算法模块,并配有示例模型、资源文件以及MeshLib、freeglut等第三方依赖,可在Windows、Ubuntu和macOS上使用。压缩包共373个文件、约7.69MB,以295个h头文件、17个m文件、4个cpp源文件为主,另含png/bmp/jpg预览图、txt/md说明文档、CMake配置及库文件,目录按算法、数据与资源分置,便于按模块检索。已有234人学习下载。读者可据此复现共形几何基础流程,理解工程组织、跨平台编译配置与观察器、Hodge分解等源码入口,适合作为课程作业和算法实现的参考框架。

1. 共形几何不是玄学:一张展开就撕烂的三角网格说明了什么

把一张人脸扫描网格摊平到平面上,十有八九会在鼻翼两侧翻出几个三角形,或者整块贴图被拉成一条细带。这不是代码写错了,而是"共形"两个字本身就带着硬约束:保角就意味着面积必然被牺牲,曲率集中处必然出问题。Conformal-Geometry 这套 MATLAB 资源干的事情,就是把曲面到平面、曲面到球面的保角映射拆成能算的稀疏线性系统——余切拉普拉斯、最小二乘共形映射、离散 Ricci 流,一条一条摆出来给你改。它适合已经会用 MATLAB、但被网格参数化卡住的人:做贴图展开、做曲面展平、做几何处理方向的课程设计,都能直接换掉输入数据跑自己的模型。下面从数据结构讲到调参,中间翻过的车一并写出来。

2. 半边结构与余切权重:为什么你的第一版展开总会翻几个面

2.1 半边结构不是炫技,是为了让边界能被"走"出来

三角网格最朴素的存法是两个数组:顶点坐标V(nv×3)和面片索引F(nf×3)。这个存法足够画图,但不够做参数化。共形映射要解的是顶点上的方程,而方程的边界条件来自"边界环",边界环要靠"这条边的对面是谁"一路串下去。没有邻接关系,你只能每次用ismember现搜,几千个面还能忍,几十万个面直接卡死。

半边结构把每条有向边当成一个对象,记录起点、终点、孪生半边(反向的那条)、所属面、同面内的下一条半边。有了孪生指针,"这条边是不是边界"就退化成一次判空,边界环就是顺着"边界半边的终点出发的下一条边界半边"一路走。下面这段是我常用的构建方式,用排序代替哈希表,避免containers.Map在大网格上的性能塌方:

function [half, rings] = build_halfedge(V, F) % 输入 % V : nv x 3 顶点坐标 % F : nf x 3 面片索引,1-based,逆时针(法向朝外) % 输出 % half : (3*nf) x 5,列 = [起点, 终点, 孪生, 面号, 同面下一条] % rings : cell,每个元素是一条有序边界顶点环 nf = size(F,1); nv = size(V,1); nh = 3*nf; half = zeros(nh, 5); for f = 1:nf for k = 1:3 h = (f-1)*3 + k; k2 = mod(k,3) + 1; half(h,1) = F(f,k); % 起点 half(h,2) = F(f,k2); % 终点 half(h,4) = f; % 所属面 half(h,5) = (f-1)*3 + k2; % 同面下一条半边 end end % 用 (min,max) 顶点对排序找孪生,比哈希稳,内存也可控 key = sort([half(:,1), half(:,2)], 2); [~, ord] = sortrows(key); for ii = 1:2:nh-1 h1 = ord(ii); h2 = ord(ii+1); if isequal(key(h1,:), key(h2,:)) half(h1,3) = h2; half(h2,3) = h1; end end % 边界环提取:构造"起点 -> 边界半边"的映射 isBnd = (half(:,3) == 0); mapStart = zeros(nv,1); for h = find(isBnd)' mapStart(half(h,1)) = h; % 一个顶点若两条出边会覆盖,见 5.3 end rings = {}; used = false(nh,1); for h0 = find(isBnd)' if used(h0), continue; end ring = []; h = h0; while h > 0 && ~used(h) used(h) = true; ring(end+1) = half(h,1); h = mapStart(half(h,2)); end if numel(ring) > 2, rings{end+1} = ring; end end end

这段代码里最要紧的是F的绕序。余切权重、梯度、面积全部依赖法向方向,一旦面片是顺时针,所有计算出来的角度符号都反过来,结果是"能出图,但整张图是镜像的",而且你不会立刻发现——因为拓扑看起来完全正常。我一般会在读入之后立刻加一道自检:把所有三角面的有向面积加起来,sum(dot(cross(V(F(:,2),:)-V(F(:,1),:), V(F(:,3),:)-V(F(:,1),:)), repmat([0 0 1],nf,1), 2)),如果是负的,就执行F(:,[2 3]) = F(:,[3 2])统一翻面,别拖到后面再查。

另一个容易忽略的点是rings返回的顺序。harmonic_disk这类函数要求传入的边界是沿环有序的,顺序错了出来的不是圆盘而是自交的多边形,看上去像"参数化爆炸"。rings里每条环的顺序由半边方向决定,和面片绕序一致,所以只要F的绕序是对的,这里的顺序就天然可用。

2.2 余切权重:三个角度决定了整个矩阵的脾气

离散共形映射的核心是离散拉普拉斯算子。在三角网格上,它有一个非常干净的来源:分片线性有限元对 Dirichlet 能量的离散。对一条边(i,j),它的权重等于两个相邻三角形在对面顶点处余切之和的一半:

$$W_{ij} = \frac{1}{2}\left(\cot\alpha_{ij} + \cot\beta_{ij}\right)$$

这个权重的好处是"只依赖角度,不依赖尺度"——把整个模型放大十倍,权重完全不变,这正是保角性质在离散层面的体现。坏处也很直白:余切在钝角处为负数。一个三角形只要有一个角超过 90 度,对边权重就可能变成负的,拉普拉斯矩阵的非对角元出现正值,半正定性被破坏。这时候线性系统照样能解,但解出来的 uv 坐标可能直接翻面。

function [L, W] = cot_laplacian(V, F) % L : 余切拉普拉斯,L(i,i)=sum(W(i,:)),L(i,j)=-W(i,j) % W : 对称权重矩阵,W(i,j) = (cot α + cot β)/2 nv = size(V,1); nf = size(F,1); m = 6*nf; % 每个面 3 条边,每条边 2 个非零 I = zeros(m,1); J = I; S = I; p = 0; for f = 1:nf idx = F(f,:); P = V(idx,:); for k = 1:3 i = idx(k); % 边起点 j = idx(mod(k,3)+1); % 边终点 o = idx(mod(k+1,3)+1); % 对角顶点 u = P(mod(k,3)+1,:) - P(mod(k+1,3)+1,:); v = P(k,:) - P(mod(k+1,3)+1,:); cotA = dot(u,v) / norm(cross(u,v)); % cot = cos/sin p = p+1; I(p)=i; J(p)=j; S(p)=0.5*cotA; p = p+1; I(p)=j; J(p)=i; S(p)=0.5*cotA; end end W = sparse(I(1:p), J(1:p), S(1:p), nv, nv); W = (W + W')/2; % 抹掉浮点不对称 W = W - spdiags(diag(W),0,nv,nv); % 清掉可能出现的自环 L = spdiags(sum(W,2), 0, nv, nv) - W; end

几个参数和写法上的说明。norm(cross(u,v))等于三角形面积的两倍,把它放在分母上是正确的,因为余切本来就是cos/sin,而sin正比于面积。W = (W + W')/2不是为了美观:浮点误差会让W(i,j)和W(j,i)差一个 1e-17,sparse在后续\求解时会因为结构不对称走更慢的路径,对称化一次,后面省很多时间。W - spdiags(diag(W),...)处理的是退化情况:如果网格里出现重复顶点组成的"针状"三角形,i == j会写进对角,不清掉会让sum(W,2)重复计数。

如果模型来自扫描或布尔运算,钝角三角形会非常多,直接用原始余切权重解出来的结果常常带一片翻转。常见做法是两条路:一是翻边,把网格重连成内在 Delaunay(instrinsic Delaunay),保证所有余切非负;二是不动拓扑,在解线性系统时对负权重做截断,或者给对角加一个小正则项。前者几何更干净但要写翻边逻辑,后者一行代码但会引入轻微的角度偏差。我一般先跑截断版本确认流程通,再决定要不要上翻边。

2.3 三条路线的选型:先看拓扑,再看你要保什么

同一份L,能撑起三种完全不同的实现路线。选错了不是跑不动,是跑出来的东西不对路。

路线适用拓扑边界条件保角程度计算量典型失败模式
调和映射圆盘(单连通带边)边界必须预设内部共形,边界拉伸大一次稀疏求解边界不圆润时内部被挤扁
LSCM圆盘,可带多个固定点固定 2 个点消退化全局最小二乘意义下最优一次超定最小二乘大面积区域被压成窄条
离散 Ricci 流圆盘 / 球面 / 环面目标曲率严格离散共形(保圆 Packing 度量)迭代数十到数百次步长过大震荡不收敛

调和映射最省事但边界是"死"的:你给的边界形状直接决定了内部被拉成什么样,边界上弧长分布不均,内部就会出现一条被压扁的带子。LSCM 把"共形"写成最小二乘,允许整体自由变形,只在两个点上钉住,所以对边界形状不敏感,代价是它在最小二乘意义下最优,局部仍然可能有大畸变。Ricci 流走的是第三条路:不动坐标,动度量——迭代每个顶点的共形因子,让离散高斯曲率逼近目标值,收敛后再把度量铺回平面。它最慢,但它是三者里唯一"结果由曲率唯一决定"的,做共形模、做叶状结构必须走这条。

选型的判断顺序我一般是:先看亏格和边界数,圆盘且只要一张贴图就上调和或 LSCM;要球面参数化、要环面上的模数、要做保角等价类的比较,直接上 Ricci 流,别在另外两条路上绕。

3. 三条实现路线落地:调和映射、LSCM 与 Ricci 流的可运行代码

3.1 调和映射:把边界摁在圆上,内部解 Laplace

调和映射的求解思路非常直接:给边界顶点指定平面坐标,内部顶点的 uv 满足离散 Laplace 方程L_ii * uv_i + L_ib * uv_b = 0,移项就是一次线性求解。关键在于边界怎么摆。均匀角度分布在边界顶点数不均时会出问题——一条边上挤了 200 个点,另一条边只有 20 个,均匀分角度会把前者压成一团。按弧长重参数化能把这个畸变压下去一大截。

function uv = harmonic_disk(V, F, bnd) % bnd : 有序边界顶点索引(沿环顺序,与面片绕序一致) nv = size(V,1); [L, ~] = cot_laplacian(V, F); % 边界按累计弧长参数化到单位圆上 P = V(bnd,:); seg = sqrt(sum((P([2:end 1],:) - P).^2, 2)); s = [0; cumsum(seg(1:end-1))] / sum(seg); uv = zeros(nv,2); uv(bnd,1) = cos(2*pi*s); uv(bnd,2) = sin(2*pi*s); inner = setdiff((1:nv)', bnd(:)); A = L(inner, inner); B = -L(inner, bnd) * uv(bnd,:); % 边界项搬到右端 uv(inner,:) = A \ B; % 稀疏 Cholesky end

A是内部顶点之间的子矩阵,规模等于内部点数,稀疏且对角占优(在没有负权重的前提下),MATLAB 的\会自动选 Cholesky,几万个点通常在一秒内。B的构造顺序不能反:L(inner,bnd)是"内部行 × 边界列",乘上边界坐标才得到每个内部顶点受到的边界牵引。

这段代码有一个隐含前提:输入必须是圆盘拓扑。如果模型带多个洞(比如一个带两个孔的板),bnd会有多条环,直接塞进uv(bnd,:)会得到一个自交的边界,解出来的内部网格大面积翻转。正确做法是先沿最短路径把洞切开,把多连通区域变成单连通,切开的缝在两侧各复制一份顶点。这套切开逻辑不复杂但很长,实践中我一般先用genus和numel(rings)判断,多洞的直接走 Ricci 流,别在调和映射上硬撑。

另一个参数是圆的半径。半径只影响尺度不影响形状,因为 Laplace 方程是线性的,边界等比缩放,内部解也等比缩放。所以定成 1 就行,不要在这上面调参。

3.2 LSCM:把共形条件写成超定方程组

LSCM 的出发点和调和映射不同:它不预设边界,而是要求每个三角形的局部展开尽量接近"相似变换"。做法是把每个三角形先旋转到它自己的局部平面,得到三个二维点,然后用这三点构造共形条件的系数矩阵,把所有三角形堆起来形成一个超定系统A * x = 0,再固定两个点消掉平移旋转自由度,解最小二乘。

function uv = lscm(V, F, pin) % pin : [p1 p2] 两个固定顶点,用来消掉整体的平移旋转 nv = size(V,1); nf = size(F,1); A = sparse(2*nf, 2*nv); for f = 1:nf idx = F(f,:); P = V(idx,:); e1 = P(2,:) - P(1,:); e1 = e1 / norm(e1); nrm = cross(P(2,:)-P(1,:), P(3,:)-P(1,:)); area2 = norm(nrm); % 2 * 三角形面积 e2 = cross(nrm/area2, e1); Q = P - P(1,:); x = Q * e1'; % 局部 x 坐标 y = Q * e2'; % 局部 y 坐标 % 共形条件的复系数:w_k = dx + i*dy,按对边差分构造 wk = [ (x(3)-x(2)) + 1i*(y(3)-y(2)), ... (x(1)-x(3)) + 1i*(y(1)-y(3)), ... (x(2)-x(1)) + 1i*(y(2)-y(1)) ] / sqrt(area2/2); for k = 1:3 re = real(wk(k)); im = imag(wk(k)); c = 2*(idx(k)-1); A(2*f-1, c+1) = re; A(2*f-1, c+2) = -im; % 实部方程 A(2*f, c+1) = im; A(2*f, c+2) = re; % 虚部方程 end end % 固定两个点,剩余自由度解最小二乘 free = setdiff((1:nv)', pin(:)); colsFree = [2*free-1, 2*free]'; colsFree = colsFree(:); colsPin = [2*pin(:)-1, 2*pin(:)]'; colsPin = colsPin(:); d = norm(V(pin(2),:) - V(pin(1),:)); % 保尺度:固定两点距离 uvPin = [0 0; d 0]; b = -A(:, colsPin) * uvPin(:); x = zeros(2*nv,1); x(colsFree) = A(:, colsFree) \ b; % 超定,MATLAB 走 QR x(colsPin) = uvPin(:); uv = reshape(x, 2, nv)'; end

sqrt(area2/2)这个缩放因子来自 LSCM 的标准推导,作用是把每个三角形的能量按面积加权,面积大的三角形话语权大,避免小三角形主导解。pin选哪两个点有讲究:选得离得太近,uvPin的d很小,整个解的尺度被压得看不出来;选在模型两端比较好。固定点的方向决定了最终结果是正着放还是歪着放,这不影响畸变,但如果要和别的结果做逐点对比,最好固定成同一个方向。

LSCM 的一个常见误解是它"没有翻转"。它在最小二乘意义下最优,但最优解仍然可以在局部翻转,尤其是细长的三角形带。判翻转的办法很简单:算每个面在 uv 平面上的有向面积,和三维面上相对于某个方向的有向面积比,符号为负的就是翻面。翻转面积占比超过 1%,基本就该考虑换路线或者先做一次重网格了。

3.3 离散 Ricci 流:不动坐标,动度量

Ricci 流的逻辑更接近几何本身:它不是去找一个映射,而是去改每个顶点处的尺度,让曲率变成想要的值。对每个顶点i有一个共形因子u_i,边长按l_ij ← l_ij * exp((u_i+u_j)/2)更新。每轮由边长用余弦定理算出三角形内角,角亏K_i = 2π - Σθ_i(内部点)或π - Σθ_i(边界点),然后u_i ← u_i + step * (K_target - K_i),直到角亏收敛。

function [uv, u] = ricci_flow_disk(V, F, bnd, iters, step) % 圆盘拓扑,目标曲率:内部 0,边界按总曲率 2π 均分 nv = size(V,1); nf = size(F,1); E = [F(:,[1 2]); F(:,[2 3]); F(:,[3 1])]; E = unique(sort(E,2), 'rows'); % 唯一边表 ne = size(E,1); % 边 -> 列号索引 eIdx = containers.Map('KeyType','double','ValueType','double'); for e = 1:ne eIdx(E(e,1)*1e7 + E(e,2)) = e; end l = sqrt(sum((V(E(:,1),:) - V(E(:,2),:)).^2, 2)); % 初始欧氏边长 u = zeros(nv,1); isB = false(nv,1); isB(bnd) = true; Ktar = zeros(nv,1); Ktar(~isB) = 0; % 内部目标曲率为 0(平坦) for it = 1:iters % 由当前边长算每个顶点角亏 ang = zeros(nv,1); for f = 1:nf idx = F(f,:); for k = 1:3 a = idx(k); b = idx(mod(k,3)+1); c = idx(mod(k+1,3)+1); lab = l(eIdx(min(a,b)*1e7 + max(a,b))); lac = l(eIdx(min(a,c)*1e7 + max(a,c))); lbc = l(eIdx(min(b,c)*1e7 + max(b,c))); % 余弦定理求顶点 a 处的角 ang(a) = ang(a) + acos((lab^2 + lac^2 - lbc^2) / (2*lab*lac)); end end K = 2*pi - ang; K(isB) = pi - ang(isB); % 边界点用 π 而不是 2π err = norm(K - Ktar, inf); if err < 1e-6, break; end u = u + step * (Ktar - K); % 边长按共形因子更新,并限制单步幅度防震荡 u = max(min(u, 5), -5); l = l .* exp(0.5*(u(E(:,1)) + u(E(:,2)))); end uv = layout_from_lengths(E, l, bnd); % 由边长铺回平面,见下 end

step的建议取值范围是 0.1 到 0.5。取 1.0 在大部分网格上会直接震荡不收敛,因为角亏对u的响应不是线性的,一次走满会过冲。u的上下界截断是后悔药:某几个顶点的曲率在某一轮突然爆掉,exp会把边长拉到 1e10,后面再也拉不回来,一截断至少还能继续迭代。

layout_from_lengths是收尾的一步:从一个三角形开始,用 BFS 逐个把相邻顶点按已知边长放到平面上(两个圆求交),遇到已经放好的顶点就用最小二乘微调位置。这一步的误差主要来自三角不等式不严格成立——Ricci 流收敛后,绝大多数三角形满足,但个别退化三角形仍然会给出无解的圆交。我在实现里通常加一个容差:如果两圆相交超过1 + 1e-8,就截断成 1。这个容差设得太大,铺出来的网格会在这些区域明显起皱;设得太小,BFS 会卡死。

4. 结果验真:角度畸变、纹理回贴与跨工具比对

4.1 角度畸变不是看一眼图就行,要出数字

肉眼看 uv 图只能判断有没有大面积翻转,判断不了"保角质量"。真正的指标是逐三角形的角度畸变。做法是:对每个面,算出三维三角形三个内角,再算出 uv 三角形三个内角,取对应角的比值,最大比值就是这块面的拟共形畸变。

function qc = quasi_conformal_error(V, F, uv) % qc : nf x 1,每个面的拟共形畸变,1 表示完美保角 nf = size(F,1); qc = zeros(nf,1); for f = 1:nf idx = F(f,:); t3 = tri_angles(V(idx,:)); % 三维三个角 t2 = tri_angles([uv(idx,:), zeros(3,1)]); % uv 三个角 r = t3 ./ max(t2, 1e-12); qc(f) = max(max(r), max(1./r)); end end function t = tri_angles(P) % P : 3 x 3,返回三个内角(弧度),顺序与顶点顺序一致 t = zeros(3,1); for k = 1:3 a = P(mod(k,3)+1,:) - P(k,:); b = P(mod(k+1,3)+1) - P(k,:); t(k) = acos(dot(a,b)/(norm(a)*norm(b))); end end

统计的时候别看均值,看 95 分位和最大值。均值会被大片平坦区域拉低,真正出问题的是那 0.1% 的面。我的经验阈值是:95 分位低于 1.15 算能用,最大值超过 3 的区域基本会出视觉瑕疵,要单独标出来回到模型上定位——通常是那儿有一片钝角三角形,或者边界上有一处剧烈转折。

4.2 纹理坐标回贴:导出这一步最容易前功尽弃

uv 算对了,导不出去等于白干。OBJ 里纹理坐标靠vt行和f v/vt索引对齐,格式错一个位置,进三维软件就是一团黑或者贴图乱飞。

function write_obj_uv(path, V, F, uv) fid = fopen(path, 'w'); if fid < 0, error('open failed: %s', path); end fprintf(fid, 'v %.6f %.6f %.6f\n', V'); % 按列循环,每行一个顶点 fprintf(fid, 'vt %.6f %.6f\n', uv'); % 每行一个纹理坐标 fprintf(fid, 'f %d/%d %d/%d %d/%d\n', [F, F]'); fclose(fid); end

fprintf按列消费参数,所以V'是 3×nv,正好每列三个数一行。[F, F]'是 6×nf,每列六个数字,对应f a/a b/b c/c的格式。vt的索引和v索引在这里恰好相同,所以直接复用F,这也是为什么 uv 数组必须和 V 顶点一一对应、不能只存边界。

坐标范围也要注意。uv 落在 [0,1] 之外在 OBJ 里是合法的,纹理默认重复采样,但如果材质里设了 clamp,边界那一圈会被拉成条纹。稳妥做法是导出前统一归一化到 [0,1],同时保留一个缩放系数,回溯的时候乘回去。

4.3 和另一套实现做交叉验证

只跑一套实现,出问题的时候你不知道是算法理解错了还是数据有问题。我习惯用两个指标做交叉核对:一是总角度畸变的分布形状,如果两套实现差了一个数量级,多半是面积加权项写错了;二是边界环的定向,把 uv 边界画出来,看它是不是和输入网格的边界环一一对应。

核对项计算方式合理范围异常指向
95 分位角度畸变4.1 节的 qc< 1.15权重或面积因子错
翻转面占比uv 面有向面积符号为负的比例< 0.1%钝角三角形过多
uv 包围盒长宽比max-min 的 x/y 比与模型本身接近边界参数化塌缩
边界自交数边界折线段两两求交0边界顺序或切缝错

这套核对不需要额外的参考实现,只靠输入模型的几何统计就能做。它的价值在于:当四个指标里只有一个异常,你大概能立刻定位到哪一步出了问题,而不是对着一张 uv 图反复猜。

5. 避坑与排查:五次翻车现场的现象、原因与解法

5.1 展开图看着挺好,贴图上文字左右反了

现象:uv 图整体形状正常,没有翻转面,但把带文字的贴图贴上去,字是镜像的。

原因:面片绕序是顺时针。所有基于有向面积的量(法向、梯度、有向面积符号)全都反了,导致 uv 平面的定向和三维曲面的定向相反,几何上等价于做了一次镜面反射。因为保角映射对镜像不敏感,畸变指标完全正常,所以很难在数值上发现。

解决:读入网格后立刻算全局有向面积和,为负就F(:,[2 3]) = F(:,[3 2])。同时在harmonic_disk里保证bnd的顺序和F的绕序一致,两者不能各用各的。

5.2 解出来一片 NaN,第一行就崩

现象:uv(inner,:) = A \ B之后,uv 里出现大量 NaN 或者 1e15 量级的值。

原因:网格里存在退化三角形——三个顶点共线,或者两个顶点坐标完全相同。这时norm(cross(u,v))等于 0,cotA变成 Inf,写进稀疏矩阵后整个A被污染。扫描数据和 CAD 导出的模型里这种面非常常见。

解决:求解前做一次清理,把面积小于1e-12 * bboxDiag^2的面删掉,把距离小于1e-9 * bboxDiag的顶点合并。清理后重新走一遍边界环提取,因为删面会改变边界。这一步不要省,我吃过一次亏——一个 4 万面的模型里只有一个退化面,查了两小时。

5.3 边界环只走出一小段就断了

现象:rings里返回的环只有几十个顶点,但模型边界明显有上千个顶点。

原因:mapStart是一个nv×1的数组,如果一个顶点有两条出边(非流形顶点,两个面片只在一点相接),后写入的会覆盖前面的,走环的时候从那一点直接跳走,剩下的边界永远走不到。

解决:把mapStart改成 cell 数组,每个顶点存一个出边列表,走环时优先选未被使用的。更进一步,先做一次非流形检测:统计每个顶点的邻接面数,如果某个顶点的邻接面构成的图不连通,就是非流形顶点,直接切开或者报错让用户处理。

5.4 矩阵解出来了,但角畸变特别大,还伴随局部翻转

现象:没有 NaN,解也算完了,但 95 分位畸变到了 2 以上,翻转面占比 5%。

原因:钝角三角形太多,余切权重出现负值,L不再半正定,解出来的调和函数在负权重区域出现"振荡",表现为局部翻面。这个现象在低质量网格上极其普遍,但在指标上往往表现为"整体还行,局部爆炸"。

解决:三条路按成本排序。最省事的是在解之前把W里的负值截断成 0,代价是引入轻微的角度偏差;中等成本是给对角加1e-8 * trace(L)/nv的正则项,能压住振荡但不改拓扑;最彻底的是做 intrinsic Delaunay 翻边,把所有余切变正,这一步要写翻边逻辑,但做完之后所有下游算法都受益。

5.5 导出的 OBJ 在三维软件里是一团黑

现象:文件能打开,模型也在,但贴图完全不显示,或者显示成纯色。

原因:三种可能。一是vt行写在v行之后但f里没带/vt索引;二是f行的索引越界(MATLAB 是 1-based,OBJ 也是 1-based,这一步通常没问题,但如果做过顶点去重而忘了重映射F,就会越界);三是没有法线,某些软件对无法线的模型默认关掉光照。

解决:导出后写一个自检函数,读回文件,检查max(F(:)) <= nv、numel(uv)/2 == nv、每个f行的三段索引一致,再补一段vn行(用面法向平均到顶点)。这个自检不到二十行,但能省掉大量来回。

6. 进阶技巧:用共形模和批处理脚本把结果钉死

单张模型跑通只能说明流程没崩,要判断这套实现靠不靠谱,得看它在"有已知答案"的问题上表现如何。共形模就是个很好的标尺。一个平环(annulus)在共形意义下由内外半径之比唯一确定,这个比值是共形不变量,跟你怎么参数化无关。用离散 Ricci 流把环面参数化到平面,然后量出内外边界的周长比,取对数就是模数。同一个模型换不同的网格密度、不同的初始度量,算出来的模数应该收敛到同一个值——如果差得远,说明要么曲率目标设错了,要么layout_from_lengths的铺开误差太大。

function m = annulus_modulus(V, F, bndOuter, bndInner) % 用 Ricci 流把环面铺平,返回共形模 log(R/r) [uv, ~] = ricci_flow_annulus(V, F, bndOuter, bndInner, 300, 0.3); % 分别取内外边界的 uv 坐标,算平均半径 pO = uv(bndOuter,:); pI = uv(bndInner,:); rO = mean(sqrt(sum((pO - mean(pO,1)).^2, 2))); rI = mean(sqrt(sum((pI - mean(pI,1)).^2, 2))); m = log(rO / rI); end

参数上,iters给 300 是因为环面的曲率分布比圆盘更难收敛,边界曲率要按内外分别分配,总曲率得对得上欧拉示性数。step给 0.3 是折中:再大就震荡,再小则 300 轮不够。判断收敛不能只看err,还要看模数在最后 50 轮里的变化幅度,稳定在 1e-3 以内才算数。

另一类进阶用法是把整套流程包成一个可扫描的批处理。我通常写一个 config 结构体,把网格路径、路线选择、权重模式、正则项、目标曲率全塞进去,外层用parfor扫参数组合,结果落成 table:

cfgList = struct2table( ... combvec(1:3, [0 1e-8 1e-6], [0.1 0.3 0.5])'); % 路线 × 正则 × 步长 cfgList.Properties.VariableNames = {'route','reg','step'}; res = cell(height(cfgList), 4); parfor i = 1:height(cfgList) cfg = cfgList(i,:); [uv, info] = run_pipeline(meshPath, cfg); res(i,:) = {cfg, info.qc95, info.flipRatio, info.iters}; end

run_pipeline里统一做网格清理、算子构建、按route分支调用三条路线之一,最后返回畸变指标和迭代次数。这样一张表跑下来,你能直接看出"哪种网格该走哪条路线",而不是靠感觉猜。注意parfor里不要共享稀疏矩阵,每个 worker 各自建各自的,内存换时间。

最后一个习惯:每次改动权重或者边界参数之后,别只看最终的 uv 图,先跑一遍翻转面占比和 95 分位畸变这两个数,和上一版做差值。有几次我改了一行正则项,图上看不出差别,但畸变分位数涨了 0.3,回头一查是正则加得太大,把保角性磨掉了。从那以后我每次调参都强制走一遍这两个指标,不看图先看数,省下来的返工时间比写代码多得多。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 18:41:58

HTML+JS实现五子棋:从DOM操作到游戏逻辑

1. HTML五子棋实现思路解析 五子棋作为经典策略游戏&#xff0c;用纯前端技术实现不仅能锻炼编程能力&#xff0c;还能深入理解DOM操作与游戏逻辑的结合。我选择HTMLCSSJavaScript这个黄金组合来实现&#xff0c;原因有三&#xff1a;零依赖、跨平台、易分享。下面从棋盘绘制到…

作者头像 李华
网站建设 2026/10/26 18:41:11

LabVIEW调用海康HCNetSDK DLL:结构体对齐、预览抓图与状态机避坑

简介&#xff1a;HCNetSDK V5.3.6.30 的 Windows 库文件集合&#xff0c;面向希望在 LabVIEW 图形化编程环境中直接调用 DLL、控制海康威视摄像头与硬盘录像机的开发者&#xff0c;用于解决设备连接、实时预览、抓拍、录像回放、云台与报警处理等接口的本地集成问题。压缩包共 …

作者头像 李华
网站建设 2026/10/26 18:41:11

Arco Design Vue表格行hover高亮效果实现与优化

1. 为什么需要表格行hover高亮效果在Web应用开发中&#xff0c;表格是最常用的数据展示组件之一。当表格数据量较大时&#xff0c;用户浏览过程中容易发生"看串行"的情况。根据人机交互研究&#xff0c;当鼠标悬停在表格行上时&#xff0c;如果该行背景色发生变化&am…

作者头像 李华
网站建设 2026/10/26 18:41:08

Linux系统引导与服务控制完全指南

1. 引导过程与服务控制基础概念在计算机系统启动和运行过程中&#xff0c;引导过程和服务控制是两个至关重要的环节。引导过程&#xff08;Boot Process&#xff09;是指从按下电源键到操作系统完全加载的整个过程&#xff0c;而服务控制&#xff08;Service Control&#xff0…

作者头像 李华
网站建设 2026/10/26 18:38:20

OpenCV 4.5.5 C++工业级稳定部署指南

简介&#xff1a;本资源为OpenCV 4.5.5官方版本预编译完成的C动态链接库集合&#xff0c;面向计算机视觉初学者、算法工程师及嵌入式开发人员&#xff0c;显著降低环境搭建门槛&#xff0c;避免耗时易错的源码编译过程。压缩包共619个文件&#xff0c;主体为485个hpp头文件与56…

作者头像 李华
网站建设 2026/10/26 18:38:14

STM32H743+TouchGFX工业级HMI实战:双核协同与DMA2D零拷贝显示

简介&#xff1a;本资源是面向嵌入式开发工程师与STM32进阶学习者的TouchGFX图形界面实战工程&#xff0c;基于正点原子STM32H743IIT6核心板&#xff0c;完整实现4.3英寸800480 LCD显示与触摸交互功能&#xff0c;解决高端Cortex-M7平台GUI开发中驱动移植、HAL配置与TouchGFX集…

作者头像 李华