在建模软件里,Inset Face 看起来是一个很小的操作:选中一个面,拖一下厚度,轮廓往里缩一圈;如果再给一点 Depth,它就变成一个凹进去或者凸出来的结构。
但是,如果仅把 inset 理解成把所有顶点朝中心点拉一拉,结果基本不会太理想。
这里记录的是我实现 InsetFace 插件时整理出来的一套做法。它不是一个学术级的 mesh offset 系统,更像是面向交互式建模工具的工程实现:常见情况尽量正确,复杂情况给出可控近似,出问题时优先保证预览不炸。整体思路参考了 Blender、Maya 这类现代 3D 软件里的 inset 行为,我是做法仅供参考,如果要做生产级实现,还是建议直接去看这些软件本身的代码和数据结构设计。
如果只处理一个平面多边形,inset 的定义其实很清楚:每条边向内部平移一段距离,然后相邻平移边求交,得到新的内轮廓。
麻烦在于建模软件里的面不是孤立的二维多边形。它们在三维空间里,可能多个面连成一个区域,区域还可能不是共面的。Thickness 到底表示顶点移动距离,还是边到边的真实距离,也会影响最后的算法分支。
我最后把实现拆成了三层:
- 单面 inset
把 3D 面投影到局部 2D 平面,在 2D 里做 polygon offset,再抬回 3D。 - 普通区域 inset
每个面先做局部 inset,然后把共享顶点的切向位移做加权融合。 - 区域 EvenOffset inset
如果区域近似共面,就做统一的 2D 区域 offset;如果明显非共面,就走曲面感知的 3D 切向近似。
所以这个插件的核心大概可以写成:
InsetFace=2D polygon offset+local frame projection+half-edge boundary extraction+tangent displacement smoothing 这几个部分听起来分散,但实现里基本就是围绕这条线组织的。
3D - 2D Offset - 3D单个面 inset 最适合先变成 2D 问题。给定面顶点:
x0,x1,…,xn−1 先计算面法线。我这里用的是 Newell 风格的多边形法线累积:
nx+=(yi−yi+1)(zi+zi+1) ny+=(zi−zi+1)(xi+xi+1) nz+=(xi−xi+1)(yi+yi+1) 然后归一化:
n=∥n∥n 局部坐标系用面上的第一个点作为原点:
o=x0 再找一条有效边作为 u 方向:
u=∥x1−x0∥x1−x0 第二个基向量由法线叉乘得到:
v=n×u 这样三维点 x 就可以投影成二维坐标:
q=[(x−o)⋅u(x−o)⋅v] 二维 offset 得到新点 q′ 后,再回到 3D:
x′=o+qx′u+qy′v+dn 这里的 d 对应用户输入的 Depth。
单面流程可以概括成:
3D 多边形
→ 计算局部平面
→ 投影到 2D
→ 2D offset
→ 反投影回 3D
→ 沿法线加 Depth
这个路径后面会反复用到。区域算法看起来更复杂,但底层依然依赖稳定的局部 2D offset。
这里是真正实现轮廓偏移的地方。
OffsetLoop2D() 是整个实现里最核心的函数。输入是二维闭合多边形:
P={p0,p1,…,pn−1} 输出是内缩后的多边形:
P′={p0′,p1′,…,pn−1′} 首先要判断多边形方向。二维有向面积为:
A(P)=21i=0∑n−1cross(pi,pi+1) 其中:
cross(a,b)=axby−aybx 如果 A(P)>0,多边形是逆时针;如果 A(P)<0,则是顺时针。
为了后面统一处理,我用了一个方向符号:
s={1,−1,A(P)≥0A(P)<0 对当前顶点 pi,取前后两条边的单位方向:
d0=∥pi−pi−1∥pi−pi−1 d1=∥pi+1−pi∥pi+1−pi 二维左法向定义为:
perpLeft(x,y)=(−y,x) 于是两条边的 inward normal 写成:
n0=s⋅perpLeft(d0) n1=s⋅perpLeft(d1) 接下来分两种情况:普通 inset 和 Offset Even。
不开启 Offset Even 时,我用的是比较轻的方案:顶点沿相邻两条边 inward normal 的角平分方向移动。
角平分方向为:
b=∥n0+n1∥n0+n1 新顶点为:
pi′=pi+tb 这里 t 是 Thickness。
这个模式下,Thickness 更接近“顶点沿角平分线移动的距离”,不是严格的边到边距离。
如果该顶点的内角是 θ,实际边距大致是:
dedge≈tcos2θ 所以钝角处的实际边距会比输入厚度小,锐角处则会显得收缩更明显。这也是普通 inset 和 Offset Even 看起来不一样的原因。
这条路径的价值在于简单、快,而且稳定。作为交互式工具的默认轻量分支,它比较合适。
开启 Offset Even 后,Thickness 应该更接近真实的边距。这个时候不能只推顶点,而要平移边线。
Regular Inset vs Offset Even对当前顶点 pi,相邻两条边分别沿 inward normal 平移 t,得到两条偏移线:
L0(α)=(pi+tn0)+αd0 L1(β)=(pi+tn1)+βd1 新顶点是这两条线的交点:
(pi+tn0)+αd0=(pi+tn1)+βd1 这个点满足:
dist(pi′,Ei−1)≈t dist(pi′,Ei)≈t 也就是说,Offset Even 的语义更接近传统建模软件里的等距 inset。
但它也更容易遇到数值问题。两条边接近平行时,交点会跑得很远;角很尖时,miter 也可能被拉成一根长刺。所以这里必须加限制。
Offset 里的尖角很麻烦。两条偏移线夹角太小时,求出来的交点可能离原顶点非常远,形成一个夸张的 miter。
我的处理是:如果直接求交失败,或者结果不稳定,就退回到角平分线方向。
角平分方向仍然是:
b=∥n0+n1∥n0+n1 理论上的 miter 长度为:
ℓ=b⋅n1t 候选点为:
pi′=pi+ℓb 但实际实现里会限制长度:
∣ℓ∣≤∣t∣M 普通模式使用:
M=8.0 Offset Even 模式使用:
M=12.0 这两个数不是推导出来的,是工程里调出来的。太小会把尖角削得很平,太大又容易把预览拉飞。现在这个范围能保留一定锐利感,也不会让单个极端角把整个轮廓弄坏。
二维 offset 得到的候选轮廓不一定可用。厚度过大时,多边形可能塌缩、自交,甚至方向翻转。凹多边形和窄长面尤其容易出问题。
所以 OffsetLoop2D() 生成候选轮廓后,我会做几类检查。
面积不能接近零:
∣A(P′)∣>ε 方向不能翻转:
sign(A(P′))=sign(A(P)) 同时还要检查不能自交。
如果检查失败,我没有让它直接报错,而是缩小厚度后重试,最多尝试 8 次:
tk+1=0.5tk
有了单面 inset,Individual 模式就比较直接,每个面独立内插即可。每个选中面独立执行:
取面顶点
→ 计算面法线
→ 建立局部 2D 坐标
→ OffsetLoop2D
→ 抬回 3D
→ 生成内盖和侧壁
对面 f 中的顶点 i,最终位置可以写成:
xf,i′=of+qf,i,x′uf+qf,i,y′vf+dnf 这个模式局部稳定,特别适合多个不共面的独立面。
它的限制也很直接:两个相邻面如果共享一条边,各自独立 inset 后,新边不一定协调。区域模式就是为了解决这个问题做的。
在 Region 模式下,如果没有开启 Offset Even,我没有直接把整个区域压到一个平面里做 offset。原因很简单:区域一旦不是共面,投影平面上的距离就不再可靠,做出来的 inset 会带明显变形。
这里采用的做法是:每个面先局部 inset,得到局部位移;然后把这些位移投影到顶点切平面,再对共享顶点做加权融合。
对面 f 中的顶点 v,局部 inset 给出:
xf,vinset 局部位移为:
Δxf,vlocal=xf,vinset−xv 由于不同面法线不一样,这个位移里可能混有不一致的法向分量。于是先投影到顶点平均法线对应的切平面。
设顶点平均法线为:
nv 切向位移为:
Δxf,vtan=Δxf,vlocal−(Δxf,vlocal⋅nv)nv 权重用顶点处的角度。某个面在这个顶点占的角越大,它对最终位移的贡献也应该越大:
wf,v≈∠(eprev,enext) 最终共享顶点的切向位移为:
Δxvtan=∑fwf,v∑fwf,vΔxf,vtan 新位置为:
xv′=xv+Δxvtan+dnv 这个方法不是严格的区域 offset。它做的是在每个顶点的切空间里融合局部 inset 结果。精度上有妥协,但稳定性不错,尤其适合普通区域 inset 的交互预览。
Region + EvenOffset 是最绕的一条分支。
我早期版本这里处理得比较简单:只要用户开启均等偏移,就把整个选中区域投影到一个统一的 2D 平面里,算完 offset 后再抬回 3D。平面模型上这套方案看着没什么问题,轮廓也干净,所以一开始我没有特别想过它。
问题是插件发给别人试用后才暴露的。他拿了一个带折角的区域测试,结果折角附近的 inset 边界偏到了不该去的位置,侧壁也开始出现不协调的扭曲。下面这张图就是当时的效果。

这个 bug 的根源不在 OffsetLoop2D()。2D 里的等距偏移本身没有算错,真正的问题是统一投影这一步把折角处的局部切空间信息抹掉了。
早期做法相当于把所有切向位移都限制在同一个区域平面 Tregion 上:
Δxi2D=(qi,x′−qi,x)u+(qi,y′−qi,y)v 这个位移满足的是区域平面上的 offset 语义。可是在折角处,同一个共享顶点周围会有多个局部面,它们的切平面并不一致。先把这些面压到同一个 2D 平面里,再把结果抬回去,本质上就是用一个全局切平面替代了局部切空间。
这里还有一个容易误判的点:即使 Depth 最后仍然沿顶点平均法线加上去,问题也不会自动消失。因为 Thickness 对应的切向收缩方向已经在投影阶段被改写了。最后看到的侧壁扭曲,其实是前面边界目标位置就已经偏了。
所以后来我把 Region + Offset Even 拆成现在的两条路径:
- 近共面区域继续走统一 2D offset,因为这时投影误差还在可控范围内;
- 非共面区域不再强行压平,而是先用局部面 even inset 生成边界目标,再把位移投影到顶点切平面里传播。
这也是后面 nearly planar 判断存在的原因。它不是为了让流程更复杂,而是为了避免在折角、弯曲区域里误用单一投影平面。这个坑也提醒我,建模操作里很多“看起来可以统一成 2D 问题”的地方,最好先检查一下局部切空间有没有被一起丢掉。
区域中心取所有顶点的平均:
c=N1i=1∑Nxi 区域法线用面积加权的面法线平均:
n=f∑Afnf 归一化:
n=∥n∥n 然后从区域中找最长边方向,投影到该平面作为 u,再计算:
v=n×u 这样就得到区域级的局部坐标系。
我用了两个指标判断区域是否足够接近平面:
- 顶点到区域平面的最大距离;
- 面法线和区域法线的最大夹角。
设有效厚度为:
teff=max(∣t∣,10−4) 只有同时满足:
dmax≤0.25teff 以及:
θmax≤8∘ 才认为它是 nearly planar。
这个阈值也是偏工程的选择。直觉上,如果区域起伏相对 inset 厚度很小,把它压到一个 2D 平面里求 offset 是可以接受的。反过来,如果区域本身已经明显弯曲,统一投影就会制造更大的误差。
整体思路为边界 Offset + Laplace 插值。
对 near planar 区域,先把所有区域顶点投影到统一 2D 平面:
qi=[(xi−c)⋅u(xi−c)⋅v] 然后用半边结构提取区域边界。
半边结构做这件事很顺手:如果一条半边在选中区域内部有 pair,它就是内部边;如果没有对应的区域内 pair,它就在区域边界上。沿这些边界半边继续追踪,就能得到一个或多个闭合边界环。
这里要处理孔洞。外边界和孔洞的 offset 方向相反:
对外边界:
tloop=t 对孔洞:
tloop=−t 然后每个边界环调用:
OffsetLoop2D(loop, t_loop, true)
边界点有了目标位置后,内部点还要跟着移动。当前实现里,我固定边界,对内部点做邻接平均迭代。对内部点 i:
qi(k+1)=∣N(i)∣1j∈N(i)∑qj(k) 边界点固定不动,内部点迭代更新。
这相当于近似求解离散 Laplace 方程:
Δq=0 也就是 harmonic interpolation。
这里没有显式构造稀疏矩阵,而是用了固定次数的 Jacobi 型迭代。它不是最高效、最严格的线性系统解法,但实现简单,预览阶段也足够稳。
得到新的 2D 位置后,再回到 3D:
xi′=c+qi,x′u+qi,y′v+dni Depth 这里用的是顶点平均法线 ni,不是统一的区域法线。这样轻微弯曲的区域看起来会自然一些。
如果区域不满足近共面条件,采用曲面感知的近似路径,不再把它硬投影到一个平面里。
这条路径的思路是:边界点由局部面 even inset 结果给出,内部点通过 3D 切向平滑传播。
先对每个相邻面做局部 even inset,得到边界顶点在每个面里的局部 inset 位置:
xf,vinset 局部位移为:
Δxf,vlocal=xf,vinset−xv 然后投影到顶点切平面:
Δxf,vtan=Δxf,vlocal−(Δxf,vlocal⋅nv)nv 再用角度权重融合,得到边界目标位移:
Δxvboundary=∑fwf,v∑fwf,vΔxf,vtan 边界位移固定后,内部点在 3D 里迭代传播位移。
对内部顶点 i:
Δxi(k+1)=ΠTi∣N(i)∣1j∈N(i)∑Δxj(k) 其中 ΠTi 表示投影到顶点 i 的切平面:
ΠTi(a)=a−(a⋅ni)ni 最终位置为:
xi′=xi+Δxitan+dni 这不是严格的 geodesic offset。严格曲面 offset 要处理测地距离、曲率、局部参数化和拓扑变化,在任意三角网格上会复杂很多。
但作为建模插件里的交互路径,这个近似比较实用:它不会因为区域轻微弯曲就失效,也不会把所有点强行压到一个平面里。当前实现里,如果这条曲面感知路径失败,会回退到普通 patch inset,而不是直接报错。
这个插件里还有一个比较重要的工程结构:预览和提交分离。
预览阶段不会直接改原始网格,而是构建临时结构 TempRegion,里面保存:
- 临时顶点;
- 临时半边;
- 临时面;
- 边界环;
- 区域平面;
- inset 后的目标位置。
预览只生成可视化线条,比如新内轮廓边、原边界点到新边界点的连接线。
这个设计很实际。用户拖动参数时,插件可能一秒钟重建很多次结果。如果每次都直接写回真实拓扑,撤销、回滚、法线更新、坏几何清理都会变得麻烦。临时结构让预览轻很多,也能避免中间状态污染原始网格。
提交阶段才真正写回拓扑,大致流程是:
- 创建 inset 后的新顶点;
- 用新顶点生成内盖面;
- 用原边和新边生成侧壁四边形;
- 删除原始选中面;
- compact 网格;
- 更新法线。
侧壁四边形的顶点顺序是:
[xstartorig,xendorig,xendnew,xstartnew] 这样可以自然连接原边界和新内边界,同时保持面朝向一致。
写这类工具时经常会遇到一个 trade-off:到底要追求几何上更严格的 offset,还是先保证交互过程稳定、快速、可预期。
当前实现里有不少 trade-off:
- 内部点使用固定次数邻接平均,而不是精确稀疏线性系统;
- 非共面
Offset Even 是曲面感知近似,不是严格测地 offset; - UV 目前只做轻量处理,没有重新做完整参数化;
- 自交处理采用缩小厚度重试,而不是完整 polygon clipping。
不用说,这些确实是目前插件的限制。但对这个插件来说,首先面对的是交互场景。用户拖动参数时算法要快;普通模型上结果要稳定;遇到局部坏几何时最好有 fallback;一个极端顶点不应该让整个操作崩掉。
所以它更像一个工程化的 inset solver,而不是一个完整的 polygon offset 或 mesh parameterization 系统。近似是存在的,但尽量放在能解释、能控制的位置。
InsetFace 的决策结构大致如下:
InsetFace
│
├── Individual
│ └── 每个面独立:
│ 3D 面 → 局部 2D → OffsetLoop2D → 回到 3D
│
└── Region
│
├── EvenOffset = false
│ └── 每面局部 inset
│ → 位移投影到顶点切平面
│ → 角度加权平均
│
└── EvenOffset = true
│
├── nearly planar
│ └── 区域统一投影到 2D
│ → 提取边界环
│ → 边界 even offset
│ → 内部 Laplace 平滑
│ → 回到 3D
│
└── nonplanar
└── 局部面 inset 生成边界目标
→ 3D 切向位移平滑
→ 加 Depth