【3D 入门-9】依据 Hunyuan3D2.1 的代码来理解 SDF 和 marching cubes(下)

本文则介绍详细的水密化(Watertight)和Mesh 采样过程。

  • 补充知识:OBB (Oriented Bounding Box) 和 AABB (Axis Aligned Bounding Box),AABB 则是我们归一化后的那个 Box,我们的 3D mesh 就在其中。
  • 在下图中,白色 box 是 OBB(定向边界框),蓝色则是 AABB(轴对齐边界框)。
    在这里插入图片描述
    在这里插入图片描述

回归正题~ 先高度总结下如何得到 watertight mesh, surface.npz 和 sdf.npz 的。

  • uniform grid 的采样点:AABB 加 padding 后等间距生成的所有格点,用来计算每个点的 SDF,供 marching cubes 提取等值面(构成 watertight 网格)。
  • 表面采样点:从网格表面按面积均匀和“锐边”加权抽取的点及法线,用于生成 surface.npz。
  • 近表面/体积采样点:围绕表面加噪声得到的近表面点,以及盒内均匀随机的体积分布点,用来计算 SDF 标签,生成 sdf.npz。

1-生成 watertight 网格(闭合网格)

  • 在归一化后的包围盒基础上,扩大 5% padding,构造均匀三维网格(grid_res³ 个点,默认 256³),用来存放标量值(这里是 SDF)。
  • 对所有网格点计算到三角网格的有符号距离 SDF(libigl 的 signed_distance)。
  • 在标量场 φ = epsilon - |SDF| 上用 marching cubes 提取 0 等值面,得到闭合网格(天然“水密”)。
def Watertight(V: np.ndarray, F: np.ndarray, epsilon: float = 2.0 / 256, grid_res: int = 256):
...
grid_points = np.vstack([X.ravel(), Y.ravel(), Z.ravel()]).T.astype(np.float64)
...
sdf = call_signed_distance(grid_points, V, F, sign_type=sign_type)
ret = igl.marching_cubes(epsilon - np.abs(sdf), grid_points, grid_res, grid_res, grid_res, 0.0)
mc_verts = ret[0]
mc_faces = ret[1]
1.1 call_signed_distance 的细节
def call_signed_distance(points: np.ndarray, V: np.ndarray, F: np.ndarray, sign_type=None) -> np.ndarray:
"""Version-compatible wrapper for igl.signed_distance.
Always returns only the SDF array regardless of how many values igl returns.
"""
points = np.asarray(points, dtype=np.float64)
V = np.asarray(V, dtype=np.float64)
F = np.asarray(F, dtype=np.int64)
if sign_type is not None:
try:
ret = igl.signed_distance(points, V, F, sign_type=sign_type)
return ret[0]
except TypeError:
pass
ret = igl.signed_distance(points, V, F)
return ret[0]
  • 功能:对一组三维点 points,计算它们到三角网格 (V, F) 的有符号距离(SDF),只返回距离一项。
  • 步骤解释:
    • points/V 强制为 float64F 强制为 int64,以匹配 libigl 的接口要求。
    • 若提供了 sign_type(如 FAST_WINDING 或 PSEUDONORMAL),优先按该方式计算;若当前 libigl 版本不支持该参数(抛 TypeError),则退回到默认签名。
    • libigl 的 signed_distance 通常返回一个元组 (dist, I, C, N)
      • dist:每个点到网格的带符号距离(我们只需要这个,函数返回 ret[0]
      • I:最近三角形索引
      • C:最近点坐标
      • N:法线(版本依赖)
  • 返回值:形状为 (num_points,)dist 数组(float64),与输入 points 一一对应。
  • 备注:符号正负的物理含义取决于 sign_type 与网格朝向;本工程在后续会用 -dist 作为标签以匹配其约定。
1.2 watertight mesh 的关键输出
sdf = call_signed_distance(grid_points, V, F, sign_type=sign_type)
ret = igl.marching_cubes(epsilon - np.abs(sdf), grid_points, grid_res, grid_res, grid_res, 0.0)
mc_verts = ret[0]
mc_faces = ret[1]

在这里插入图片描述

  • 输出的 *_watertight.obj 就是这里的 mc_verts, mc_faces 写出的闭合网格:
out_path = f'{output_prefix}_watertight.obj'
if hasattr(igl, 'write_obj'):
igl.write_obj(out_path, mc_verts, mc_faces)
else:
igl.writeOBJ(out_path, mc_verts, mc_faces)

2-生成 surface.npz(表面点云与法线)

  • 随机表面采样:在网格面上按面积均匀采样 sample_num 个点,并取对应面的法线。
sample_num = 499712 // 4
random_surface, random_normal = random_sample_pointcloud(mesh, num=sample_num)
def random_sample_pointcloud(...):
points, face_idx = mesh.sample(num, return_index=True)
normals = mesh.face_normals[face_idx]
  • 锐边采样:通过“顶点法线与相邻面法线的夹角”标记“锐”顶点,筛出锐边(两端顶点都锐),按边长加权在边上采样点,并线性插值顶点法线作为该点的法线。
def sharp_sample_pointcloud(...):
...
sharp_mask = VN2 < 0.985
...
sharp_edge = (sharp_mask[edge_a] * sharp_mask[edge_b])
...
samples = w * sharp_verts_a[index] + (1.0 - w) * sharp_verts_b[index]
normals = w * sharp_verts_an[index] + (1.0 - w) * sharp_verts_bn[index]
  • 打包成两个数组,各为 N×6:前 3 列 xyz,后 3 列法线 nx ny nz,并以 float16 存储节省空间。
surface = np.concatenate((random_surface, random_normal), axis=1).astype(np.float16)
sharp_surface = np.concatenate((random_sharp_surface, sharp_normal), axis=1).astype(np.float16)
surface_data = {"random_surface": surface, "sharp_surface": sharp_surface}
  • 最终写入 *_surface.npz(包含两个键:random_surfacesharp_surface):
export_surface = f'{output_prefix}_surface.npz'
np.savez(export_surface, **surface_data)

3-生成 sdf.npz(体积/近表面采样与 SDF 标签)

  • 随机体积点:在略大于单位盒的立方体中均匀采样 n_volume_points = len(sharp_surface) * 2 个点(覆盖体内各处)。
  • 近表面点:围绕随机表面点加两种尺度的截断高斯噪声(很靠近/较靠近),围绕锐边表面点分 6 个尺度段添加噪声,得到大量“靠近表面”的采样。
n_volume_points = sharp_surface.shape[0] * 2
vol_points = (np.random.rand(n_volume_points, 3) - 0.5) * 2 * 1.05
...
random_near_points = np.concatenate([random_surface + offset1, random_surface + offset2], axis=0)
...
sharp_near_points = np.concatenate([...不同噪声尺度...], axis=0)
  • 分别对三类点(体积、随机近表面、锐边近表面)调用 igl.signed_distance 得到 SDF 距离值,并取负号作为标签(该工程的正负约定)。
vol_sdf = call_signed_distance(vol_points, mesh.vertices, mesh.faces, ...)
random_near_sdf = ...
sharp_near_sdf = ...
vol_label = -vol_sdf
random_near_label = -random_near_sdf
sharp_near_label = -sharp_near_sdf
  • 打包为以下键值并保存为 float16:
    • vol_points: (M,3)
    • vol_label: (M,)
    • random_near_points: (R,3)
    • random_near_label: (R,)
    • sharp_near_points: (S,3)
    • sharp_near_label: (S,)
data = {
"vol_points": vol_points.astype(np.float16),
"vol_label": vol_label.astype(np.float16),
"random_near_points": random_near_points.astype(np.float16),
"random_near_label": random_near_label.astype(np.float16),
"sharp_near_points": sharp_near_points.astype(np.float16),
"sharp_near_label": sharp_near_label.astype(np.float16),
}
  • 最终写入 *_sdf.npz
export_sdf = f'{output_prefix}_sdf.npz'
np.savez(export_sdf, **sdf_data)

关键点与参数含义

  • watertight 的本质:用 SDF 的等值面重建闭合曲面,规避原始网格的洞/非流形/自交等问题。由 grid_res 控制细节、epsilon 控制等值面的“偏移厚度”。
  • surface.npz:用于表面点云监督,既有“均匀表面”也有“锐边表面”,每条记录是 xyz+normal。
  • sdf.npz:体积/近表面点的 SDF 标签(取了负号),常用于 SDF/隐式场训练,能同时覆盖体内(远离表面)与表面附近的细节。
posted @ 2025-09-07 12:02  yfceshi  阅读(26)  评论(0)    收藏  举报