UV展开的本质是把三维网格表面映射到二维平面,这个映射过程必然会产生几何变形。对于圆柱面、圆锥面这类可展曲面,可以使用等距展开做到完全无拉伸;但对于球面、角色身体、复杂地形等不可展曲面,任何展开都会引入拉伸或压缩。拉伸会导致纹理细节被拉长、法线方向偏移、光照烘焙出现伪影。要解决拉伸,不能靠手动拖动UV点去“看起来不歪”,而是需要从映射的数学性质入手,选择合适的参数化算法。本文围绕保角映射与面积保持映射两类主流方法展开,帮助读者建立量化拉伸、选择算法的判断依据。

一、UV拉伸的根本原因:从3D到2D的映射失真
三维网格中的每个顶点在UV空间中都有对应的二维坐标,这个坐标对(u, v)与三维位置(x, y, z)之间的映射关系决定了一切纹理采样的质量。对于三角形面片,映射可以看作一个仿射变换,它把三角形在三维空间中的形状映射到二维平面。如果映射前后三角形的每个角都保持不变,那么局部形状就没有角度扭曲;如果映射前后三角形的面积比例保持不变,那么局部区域就没有面积拉伸。但这两者往往不可兼得。
从微分几何角度看,映射的拉伸程度可以用雅可比矩阵J描述。J是二维到三维(或三维到二维)的导数矩阵,对每个三角形而言,J的奇异值σ1和σ2反映了两个主方向上的拉伸系数。当σ1=σ2=1时,这个区域是等距的,没有拉伸也没有压缩;当σ1=σ2但都不等于1时,是相似变换,只有缩放没有角度扭曲;当σ1与σ2不相等时,就存在各向异性拉伸,纹理在每个方向上的缩放不一致,表现为局部拉长或压扁。因此,量化拉伸的一个常用指标是L2拉伸能量,定义为σ1²+σ2²的平均值,另一个是Symmetric Dirichlet能量,定义为σ1²+σ2²+σ1⁻²+σ2⁻²的平均值,后者更全面地惩罚拉伸和压缩。
理解了这个数学背景之后,才能明白为什么单纯的“把UV摊平”无法解决所有问题。固定边界的UV展开可以帮助消除重叠,但内部仍可能存在严重拉伸。自由边界的展开通过释放边界顶点位置来降低整体变形,但可能产生边界自交或面积比例失衡。接下来两节分别讨论保角映射和面积保持映射的核心思想与实现手段。
二、保角映射:角度不变的局部相似变换
保角映射又称共形映射,它的核心约束是让映射保持局部角度不变。在连续曲面上,共形映射满足Cauchy-Riemann方程,即映射的偏导数满足u_x=v_y且u_y=-v_x。在离散三角网格上,最流行的保角方法是最小二乘共形映射(Least Squares Conformal Maps,LSCM),它由Lévy等人提出,将共形条件转化为每个三角形上的线性约束。LSCM允许自由边界,能够显著降低整体角度失真,但面积可能会被压缩或拉伸得很厉害。
LSCM的核心思想是:对于每个三角形,在局部坐标系中写出顶点坐标的复数形式,然后要求映射前后的两个复数坐标满足共形关系。具体地,设三角形在三维空间中的两个边向量为e1和e2,在UV空间中的对应边向量为f1和f2,则共形变换可以用复数关系表达为 f1 + i f2 = λ (e1 + i e2),其中λ是一个复数比例因子。消去λ之后,可以得到一个关于UV坐标的线性方程。将所有三角形的方程组装起来,得到一个稀疏线性系统 Ax = b,求解该系统的解就是最小二乘意义下的共形映射。
下面的Python代码演示了LSCM矩阵组装的基本过程。它假设网格已经存储为顶点列表和三角形索引,并使用固定两个顶点以避免零解。代码中调用了scipy的稀疏矩阵求解器。
import numpy as np
from scipy.sparse import lil_matrix
from scipy.sparse.linalg import spsolve
def build_lscm_matrix(vertices, faces, pinned_verts):
"""
vertices: shape (n, 3) 三维顶点坐标
faces: shape (m, 3) 三角形顶点索引
pinned_verts: list of (vertex_index, u, v) 固定至少两个顶点
返回稀疏矩阵A和右侧向量b,使得 A @ uv = b
"""
n = len(vertices)
A = lil_matrix((2*n, 2*n))
b = np.zeros(2*n)
for tri in faces:
# 使用3x3仿射变换来推导共形条件
v0, v1, v2 = vertices[tri[0]], vertices[tri[1]], vertices[tri[2]]
# 局部基
e1 = v1 - v0
e2 = v2 - v0
# 计算三角形在三维空间中的局部坐标投影到自身平面,简化处理:使用边长和面积
# 这里使用标准LSCM公式:对于每个三角形,构建2x3块
# 为简洁,采用复平面方法
# 实际生产可参考OpenNL或Blender源码中的LSCM实现
# 此处用简化的能量梯度作为示意
pass
# 固定点的约束
for idx, u_val, v_val in pinned_verts:
A[2*idx, 2*idx] = 1.0
A[2*idx+1, 2*idx+1] = 1.0
b[2*idx] = u_val
b[2*idx+1] = v_val
return A.tocsr(), b
上面的代码仅展示了结构框架,完整的LSCM组装需要为每个三角形计算局部共形能量梯度。在工程实现中,通常使用Eigen的SparseLU或者UMFPACK来求解。一个更实用的方式是调用现成的库,比如libigl的igl::lscm函数,它封装了LSCM算法,只需传入网格和固定约束即可得到UV坐标。
保角映射的优点非常明显:纹理上的圆在映射后仍然保持圆的形状(局部),不会出现椭圆化扭曲,因此适合需要保持纹理方向特征的场景,例如文字标签、砖缝、法线贴图中的微小凹凸方向等。但它的缺点同样突出:面积比例完全不受控制,某些区域可能被压缩到极小的UV空间,导致采样不足;另一些区域则被拉伸得很大,浪费纹理分辨率。因此保角映射很少单独用于最终UV布局,通常需要配合面积均衡化后处理。
三、面积保持映射:等积参数化的原理与算法
面积保持映射又称等积参数化,目标是让UV空间中每个三角形面积与其三维空间中面积的比例保持常数。理想的等积映射不仅保持全局面积比,还要求每个局部区域都均匀。这在连续微分几何中对应雅可比矩阵行列式为常数的约束,即det(J)=const。离散情况下,就是每个三角形的UV面积与3D面积的比值相等或接近。
实现面积保持的经典方法包括ARAP(As-Rigid-As-Possible)参数化、ASAP(As-Similar-As-Possible)参数化以及基于最优传输(Optimal Transport)的面积均衡技术。ARAP允许自由边界,在局部保持刚性变换的同时最小化面积失真,但计算量较大;ASAP在ARAP基础上进一步约束局部相似性,兼顾了角度和面积。另一种更直接的方法是在LSCM结果的基础上做面积校正:先计算每个三角形的面积比例,然后迭代调整顶点位置,使得所有三角形的面积比值趋近于全局中位数。这种方法简单,但可能破坏角度保真度。
下面给出一个简单的面积校正迭代示例。假设我们已经有一个初始UV坐标,以及每个三角形在3D空间中的面积。通过缩放每个三角形的重心坐标来调整UV位置,使面积比例趋向一致。
def area_equalize(uv, faces, area_3d, iterations=100, step=0.5):
"""
uv: 初始UV坐标, shape (n, 2)
faces: 三角形索引, shape (m, 3)
area_3d: 每个三角形在三维空间中的面积, shape (m,)
返回校正后的uv坐标
"""
import numpy as np
m = len(faces)
# 计算初始UV面积
def uv_area(tri):
a, b, c = uv[tri[0]], uv[tri[1]], uv[tri[2]]
return 0.5 * abs((b[0]-a[0])*(c[1]-a[1]) - (c[0]-a[0])*(b[1]-a[1]))
area_uv = np.array([uv_area(t) for t in faces])
# 全局目标面积比例(使用面积总和)
total_uv = area_uv.sum()
total_3d = area_3d.sum()
target_ratio = total_uv / total_3d
for _ in range(iterations):
# 计算每个三角形的当前比例与目标比例的差距
current_ratio = area_uv / area_3d
scale_factors = (target_ratio / current_ratio) ** step
# 根据缩放因子更新三角形顶点(暂不考虑邻接平均,简化)
for i, tri in enumerate(faces):
center = np.mean(uv[list(tri)], axis=0)
sf = scale_factors[i]
for v in tri:
uv[v] = center + sf * (uv[v] - center)
# 重新计算UV面积
area_uv = np.array([uv_area(t) for t in faces])
return uv
这个简单的迭代缩放会在三角形之间产生不连续,因为每个三角形独立缩放会导致共享顶点被多次移动。实际算法需要将顶点移动量在相邻三角形之间平均,或者使用基于梯度的优化。更稳健的做法是定义一个能量函数,例如Symmetric Dirichlet能量,然后使用Newton迭代或L-BFGS优化器最小化该能量。该能量同时惩罚拉伸和压缩,通过设置不同的权重可以在保角和保面积之间过渡。
面积保持映射在光照贴图烘焙和纹理图集打包中非常重要。如果UV展开的面积比例不均匀,小块区域可能分配到过多纹理像素,而大块区域则像素不足,导致贴图分辨率浪费或细节丢失。通过面积保持,可以确保每个三角面获得与其三维面积成正比的UV面积,从而充分利用纹理空间。但面积保持往往牺牲角度保真,导致纹理上原本垂直的线条在UV上不再垂直,出现斜向拉伸或剪切。
四、混合约束与拉伸率可视化:工程中的实际选择
在实际的DCC工具或自研资产管线中,很少会纯粹使用保角映射或纯面积保持映射。Blender的智能UV展开(Smart UV Project)以及Unfold3D的商业算法通常都采用一种混合能量:以保角项为主,加上面积均衡项作为正则化,再配合岛屿填充和边界优化。这种混合的权重可以由用户调节,例如Blender的“角度限制”与“面积权重”参数。
量化UV拉伸的最直接方法是计算每个三角形的L2拉伸值,并将该值映射为颜色显示在UV编辑器或3D视图中。红色表示拉伸严重,蓝色表示压缩,绿色表示接近等距。Blender的UV编辑器中有内置的“显示拉伸”选项,可以切换面积拉伸或角度拉伸。对于自研工具,可以在导出UV后计算每个三角形的σ1和σ2,然后确定颜色。下面是一个计算拉伸系数的Python函数,它返回每个三角形的最大奇异值和最小奇异值,用于可视化。
import numpy as np
def compute_triangle_stretch(vertices_3d, vertices_2d, faces):
"""
计算每个三角形的拉伸奇异值。
vertices_3d: (n,3) 三维坐标
vertices_2d: (n,2) UV坐标
faces: (m,3) 索引
返回 sigma_max, sigma_min 两个数组,形状 (m,)
"""
m = len(faces)
sigma_max = np.zeros(m)
sigma_min = np.zeros(m)
for i, tri in enumerate(faces):
# 3D边向量
e1_3d = vertices_3d[tri[1]] - vertices_3d[tri[0]]
e2_3d = vertices_3d[tri[2]] - vertices_3d[tri[0]]
# 2D边向量
e1_2d = vertices_2d[tri[1]] - vertices_2d[tri[0]]
e2_2d = vertices_2d[tri[2]] - vertices_2d[tri[0]]
# 构建3x2雅可比矩阵 J = [e1_3d e2_3d] * inverse([e1_2d e2_2d])
# 这里先求2D基的逆矩阵
A2 = np.column_stack((e1_2d, e2_2d))
# 检查是否退化
if abs(np.linalg.det(A2)) < 1e-12:
sigma_max[i] = sigma_min[i] = 0.0
continue
invA2 = np.linalg.inv(A2)
J = np.column_stack((e1_3d, e2_3d)) @ invA2
# SVD分解
u, s, vh = np.linalg.svd(J)
sigma_max[i] = s[0]
sigma_min[i] = s[1]
return sigma_max, sigma_min
根据奇异值,可以计算出每个三角形的L2拉伸为sqrt(σ1²+σ2²),面积失真为σ1*σ2。通常希望所有三角形的L2拉伸都接近1.4以下,超过2.0就表示严重拉伸,需要重新展开。在实际项目中,可以通过反复调整固定点位置、切割接缝数量以及混合权重来优化。另一个实用技巧是:对于角色面部,优先使用保角映射以保持五官特征不扭曲;对于地形或建筑平面,优先使用面积保持映射以保证光照贴图均匀。对于同时包含两种特征的模型,可以分区域展开,将不同的参数化算法应用于不同的UV岛。
总结来说,解决UV展开拉伸问题没有一劳永逸的万能算法,理解保角映射与面积保持映射的数学特性,才能根据资产类型和用途做出合理取舍。使用量化工具检验拉伸率,比单纯依赖视觉判断要可靠得多。当拉伸超出阈值时,尝试改变边界约束、调整能量权重,或者增加切割线,往往能获得更好的结果。