Warp 新增 warp.geometry 模块:基于设备端并行与 CUDA Graph 捕获的 Delaunay 边翻转(delaunay_edge_flip)全解析
【免费下载链接】warpA Python framework for GPU-accelerated simulation, robotics, and machine learning.项目地址: https://gitcode.com/GitHub_Trending/warp/warp
本篇技术指南围绕 Warp 仓库新增的warp.geometry几何处理模块展开,核心讲解其中两个入口:用于 2D 三角网格并行、原地 Delaunay 边翻转的warp.geometry.delaunay_edge_flip(),以及其支撑性的三角形-三角形邻接构建函数warp.geometry.tri_tri_adjacency()。读完本文,你将掌握这两个 API 的完整参数语义、前置条件、与 CUDA Graph 捕获的集成方式,以及它们背后的并行算法(半边区段、原子声明、设备端收敛循环)是如何在 Warp 源码与测试中被实现和验证的。
一、模块概览:warp.geometry 提供了什么
warp.geometry是 Warp 中面向几何处理的新模块,模块 docstring 将其定位为“处理 2D/3D 几何及其关联拓扑数据结构(如网格)的工具函数”,并且必须显式导入才能使用:
import warp.geometry本次新增的核心能力是:在设备上(CPU 或 CUDA)对 2D 三角网格执行并行、原地的 Delaunay 边翻转,将非 Delaunay 的内部边逐轮并行翻转,直到所有内部边满足 Delaunay 条件或达到最大轮数;同时配套提供三角形邻接关系构建工具。两者全程运行在设备端,且均支持 CUDA Graph 捕获。
公开 API 与内部实现的分层
从 warp/geometry.py 可以看到,公开模块只是一个薄封装,真正的实现位于 warp/_src/geometry.py:
from warp._src.geometry import delaunay_edge_flip as delaunay_edge_flip from warp._src.geometry import find_triangle_neighbor_edge_index as find_triangle_neighbor_edge_index from warp._src.geometry import tri_tri_adjacency as tri_tri_adjacency而 warp/_src/geometry.py 中还有两处被注释掉的导出:
# Don't expose these quite yet in case we want to change the naming conventions. # from warp._src.geometry import in_circle as in_circle # from warp._src.geometry import signed_area as signed_area也就是说,几何谓词in_circle(外接圆判定)与signed_area(有向面积)目前仍是内部可复用但尚未公开的@wp.func,官方文档 docs/api_reference/warp_geometry.rst 中列出的公开成员共有三个:delaunay_edge_flip、find_triangle_neighbor_edge_index、tri_tri_adjacency。
二、核心 API:delaunay_edge_flip 的参数与返回语义
delaunay_edge_flip()在 warp/_src/geometry.py 中定义,完整签名如下:
def delaunay_edge_flip( positions: wp.array[wp.vec2], indices: wp.array2d[wp.int32], ref_positions: wp.array[wp.vec2] | None = None, max_passes: int = 1000, area_epsilon: float = 0.0, ref_area_epsilon: float = 1.0e-10, ) -> wp.array[wp.int32]:参数详解
| 参数 | 类型 | 默认值 | 含义 |
|---|---|---|---|
positions | wp.array[wp.vec2](一维) | 必填 | vertex_count个顶点的 2D 坐标,不会被修改 |
indices | wp.array2d[wp.int32],形状(num_tris, 3) | 必填 | 三角形顶点索引,原地更新为翻转后的连接关系 |
ref_positions | wp.array[wp.vec2] | None | 可选的参考配置顶点坐标。提供后,会在参考配置下产生退化三角形的翻转被拒绝,适用于“工作网格是参考网格的形变且必须保持非退化”的场景 |
max_passes | int | 1000 | 并行翻转的最大轮数,停止条件之一;必须>= 1 |
area_epsilon | float | 0.0 | 翻转产生的每个三角形所需的最小有向面积,用于防止产生反向(inverted)或薄片(sliver)三角形;必须有限且非负 |
ref_area_epsilon | float | 1.0e-10 | 应用于ref_positions的退化阈值;必须有限且非负 |
返回值
返回一个位于indices.device上的、形状为(1,)的int32设备数组,其中保存被翻转的边总数。注意它不是Python 整数,即使网格为空也总是返回设备端累加器(见 warp/_src/geometry.py 的空网格分支)。在 CUDA Graph 捕获场景下,必须在重放(replay)之后再读取该值。
输入前置条件与校验
- 网格必须是流形(每条边被 1 或 2 个三角形共享),且三角形具有一致的逆时针绕序(有向面积为正);
positions、ref_positions必须与indices在同一设备上,否则抛出ValueError;indices.ndim != 2或indices.shape[1] != 3时抛出ValueError;max_passes < 1、area_epsilon/ref_area_epsilon非有限或为负时同样抛出ValueError(见 warp/_src/geometry.py)。
这些校验有对应的测试覆盖:warp/tests/geometry/test_delaunay.py 验证了max_passes=0/-1、area_epsilon/ref_area_epsilon取-1.0、nan、inf时均被拒绝,且被拒绝的调用不会改动输入连接关系。
最小可用示例
以测试中“薄四边形翻转单条边”的用例(warp/tests/geometry/test_delaunay.py)为例:
import numpy as np import warp as wp import warp.geometry points = np.array([[-3.0, 0.0], [3.0, 0.0], [0.0, 1.0], [0.0, -1.0]], dtype=np.float32) tris = np.array([[0, 1, 2], [1, 0, 3]], dtype=np.int32) # 两个三角形均为逆时针 positions = wp.array(points, dtype=wp.vec2) indices = wp.array(tris, dtype=wp.int32) num_flips = int(warp.geometry.delaunay_edge_flip(positions, indices).numpy()[0]) print(num_flips) # 1:翻转了一条内部边 print(indices.numpy()) # 连接关系已原地更新,共享边变为垂直对角线 (2, 3)三、配套工具:tri_tri_adjacency 与 find_triangle_neighbor_edge_index
tri_tri_adjacency:构建三角形邻接表
tri_tri_adjacency()(warp/_src/geometry.py)为三角网格构建三角形-三角形邻接关系,签名如下:
def tri_tri_adjacency( indices: wp.array2d[wp.int32], vertex_count: int | None = None, return_neighbor_edge_indices: bool = True, ):| 参数 | 默认值 | 说明 |
|---|---|---|
indices | 必填 | (num_tris, 3)的int32三角形顶点索引数组 |
vertex_count | None | 网格顶点数。为None时内部通过一次设备端归约推断为“最大顶点索引 + 1”;在 CUDA Graph 捕获上下文中必须显式传入,否则抛出RuntimeError |
return_neighbor_edge_indices | True | 为True时额外计算并返回每个邻接三角形中共享边对应的局部边索引;为False时只构建邻接数组,更快且占用内存更少 |
返回值约定:
return_neighbor_edge_indices=True:返回(triangle_neighbors, neighbor_edge_indices)两个(num_tris, 3)的int32数组;return_neighbor_edge_indices=False:仅返回单个triangle_neighbors数组。
数组语义非常明确(warp/_src/geometry.py):
triangle_neighbors[t, j]是与三角形t共享“局部顶点j对边”的相邻三角形编号,该对边即连接局部顶点(j + 1) % 3与(j + 2) % 3的边;边界边对应位置为-1;neighbor_edge_indices[t, j]是该共享边在相邻三角形中的局部边索引。
测试用例 warp/tests/geometry/test_delaunay.py 给出了直观验证:两个共享边(0, 2)的三角形(0,1,2)与(0,2,3),得到triangle_neighbors[0,1] == 1(边(0,2)在三角形 0 中位于局部顶点 1 的对侧)、neighbor_edge_indices[0,1] == 2,其余边界边均为-1。
find_triangle_neighbor_edge_index:按需恢复单条邻接边索引
find_triangle_neighbor_edge_index(triangle_neighbors, triangle, neighbor)(warp/_src/geometry.py)是一个@wp.func,用于在 kernel 内查找neighbor在triangle的局部边索引:返回满足triangle_neighbors[triangle, j] == neighbor的j,若两者不相邻则返回-1。当tri_tri_adjacency以return_neighbor_edge_indices=False调用后,可用它按需恢复单个值,而不必长期持有neighbor_edge_indices数组。
一个完整的邻接构建示例
import warp as wp import warp.geometry indices = wp.array(np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32), dtype=wp.int32) # 推荐显式传入 vertex_count(网格捕获场景下必须传入) triangle_neighbors, neighbor_edge_indices = warp.geometry.tri_tri_adjacency( indices, vertex_count=4 ) # 只构建邻接、跳过边索引:更快、内存更省 triangle_neighbors_only = warp.geometry.tri_tri_adjacency( indices, vertex_count=4, return_neighbor_edge_indices=False )四、算法原理:设备端全程无主机同步的并行实现
4.1 半边区段:基于 counting-sort 的邻接构建
tri_tri_adjacency的底层实现(warp/_src/geometry.py)没有采用全局键排序,而是把每条半边按其两个端点中较小者分桶(counting sort 思路):
_count_vertex_edges:每个线程(一个三角形)将三条半边分别计入其较低端点对应的桶计数(atomic_add);warp.utils.array_scan:对计数做前缀和得到桶偏移;_scatter_vertex_edges:将每条半边散射进对应桶,记录较高端点与打包后的半边编号tri * 3 + local_edge;_match_vertex_buckets:每个线程独占一个顶点的桶区间,在桶内两两配对“相同较高端点”的半边,写入triangle_neighbors(每个顶点的平均度约 6,二次内层扫描只遍历少数条目)。
整个过程通过warp.utils.array_scan与设备端原子操作完成,不需要任何主机同步——这为后续的 CUDA Graph 捕获铺平了道路。一个值得注意的约束也因此产生:当vertex_count=None时需要通过一次设备端归约(_reduce_max_vertex_index)推断顶点数,再通过.numpy()回读主机端,这一主机同步在捕获上下文里是禁止的,于是 warp/_src/geometry.py 在device.is_capturing时直接抛出RuntimeError,对应的捕获期测试见 warp/tests/geometry/test_delaunay.py。
4.2 翻转判定:链接条件 + 外接圆判定
单条边是否翻转由_DelaunayFlipper._edge_should_flip(warp/_src/geometry.py)决定,顺序如下:
- 链接条件(link condition):翻转把边
ab换成对角cd,生成三角形(c, a, d)与(c, d, b)。只有当四边形a, d, b, c凸、即两个新三角形均有向面积为正时才继续(_EdgeFlipper._link_condition_ok,warp/_src/geometry.py),否则直接返回False; - Delaunay 判定:计算
in_circle行列式_in_circle_det(pc, pa, pb, pd)(warp/_src/geometry.py)。对于逆时针三角形(c, a, b),当对侧顶点d落在其外接圆内部时结果为正,此时需要翻转;<= 0.0(外部或四点共圆)则保持原样。这是标准浮点 in-circle 行列式,对良态配置准确,但如 warp/_src/geometry.py 的 docstring 所述并非精确几何谓词; - 参考配置保护:若提供了
ref_positions,还会检查两个新三角形在参考配置下的有向面积绝对值是否小于ref_area_epsilon,若是则拒绝翻转(防止参考网格退化)。
谓词本身的正确性有独立测试验证:warp/tests/geometry/test_delaunay.py 在单位直角三角形上确认signed_area对逆时针返回+0.5、顺时针返回-0.5,in_circle对内点(0.4, 0.4)返回 1、对外点(2.0, 2.0)返回 0。
4.3 并行独立集:原子声明 + 位反转优先级
为了让每轮能并行翻转尽可能多的互不冲突的边,实现采用了声明(claim)竞争机制:
_stake_flip_claims(warp/_src/geometry.py):每个线程(一个三角形)检查自己的三条边,对满足翻转条件的边计算优先级prio,并通过wp.atomic_max竞争其可能读写的四行{t, n, n_bc, n_ad}的“所有权”;- 优先级由
_edge_priority(warp/_src/geometry.py)给出。实现要点是:朴素地使用t * 3 + j虽然唯一,但随三角形编号单调——对网格生成器(grid、marching cubes、细分等)产出的、三角形编号带有空间局部性的网格,这会让冲突边链每轮只收敛一行,退化到O(n)轮;而把(非零)键的32 位按位反转(SWAR 位反转,_reverse_bits32,warp/_src/geometry.py)后,既保持双射(唯一性、正确性不受影响),又打散了与三角形编号的相关性,且0被保留为声明数组的“未声明”哨兵; _apply_won_flips(warp/_src/geometry.py):只有持有对应行声明的边才执行拓扑变更——交换对角、更新triangle_neighbors中四条相关邻接槽位、并重定向两个外侧邻居,最后atomic_add累计翻转数。先查声明再读可变拓扑,保证并发翻转不会互相污染数据。
4.4 设备端收敛循环:wp.capture_while
翻转的主循环(warp/_src/geometry.py)每轮执行“清声明 → 竞争声明 → 应用获胜翻转 → 记录轮次”三步,而是否进入下一轮由_record_pass(warp/_src/geometry.py)单线程地把累计翻转数与pass_count < max_passes写入设备数组,再交给wp.capture_while(condition, _flip_pass)判断:
wp.capture_while(condition, _flip_pass)wp.capture_while(warp/_src/context.py)在 CUDA Graph 捕获下会记录条件图节点(conditional graph nodes),否则将条件回读主机端决定何时停止。因此这个收敛循环无需主机同步,天然可被 CUDA Graph 捕获。
4.5 收敛行为与对抗性测试
测试 warp/tests/geometry/test_delaunay.py(test_flip_grid_sheared_converges_in_few_passes)专门构造了教科书式的对抗用例:编号逐行递增、且每条内部对角线都非 Delaunay 的剪切网格。若优先级退化为单调的t * 3 + j,声明竞争会沿列级联,每轮只解决一行,需要num_rows - 1轮;而当前实现用max_passes=5即可在 40×40 的剪切网格上收敛到 Delaunay,验证了期望的O(log n)级轮数。该测试还提示:若未来此测试失败,应优先检查_edge_priority()是否回归为单调于三角形编号的实现。
五、与 CUDA Graph 捕获的深度集成
5.1 为什么可以捕获
整个管线(邻接构建、声明竞争、翻转应用、轮次记录)都由设备端 kernel 和wp.capture_while驱动,没有中途的主机同步,因此可以被wp.ScopedCapture完整记录。测试 warp/tests/geometry/test_delaunay.py 展示了完整的捕获-重放流程。
5.2 捕获使用的五个注意点
vertex_count必须显式传入tri_tri_adjacency:捕获期内不允许None触发的主机回读(会抛RuntimeError);- CUDA 条件图节点需要 Toolkit 与驱动 12.4+:可用
wp.is_conditional_graph_supported()检测,不支持时测试会跳过(warp/tests/geometry/test_delaunay.py); - 捕获前预热分配与模块加载:测试在捕获前先跑一次翻转以预热设备分配(radix-sort scratch 等),并调用
wp.load_module(warp.geometry, device=device)预加载 kernel,避免捕获区内触发首次分配或编译(warp/tests/geometry/test_delaunay.py); - 捕获只记录不执行:捕获期间网格连接关系保持不变,重放后才生效;
- 返回值在重放后才有效:
delaunay_edge_flip返回的设备累加器在捕获模式下记录的是操作而非结果,需在wp.capture_launch(capture.graph)之后读取。
5.3 捕获示例骨架
import warp as wp import warp.geometry # 预热:完成模块加载与设备分配 warp.geometry.delaunay_edge_flip( wp.array(points, dtype=wp.vec2, device=device), wp.array(tris, dtype=wp.int32, device=device), ) wp.load_module(warp.geometry, device=device) with wp.ScopedDevice(device): with wp.ScopedCapture(force_module_load=False) as capture: total = warp.geometry.delaunay_edge_flip(positions, indices) # tri_tri_adjacency 在捕获内使用时必须显式传 vertex_count wp.capture_launch(capture.graph) print(int(total.numpy()[0])) # 重放后读取翻转总数测试还验证了图的可重放稳定性:捕获的图每次重放都会基于当前连接关系重建邻接,因此对已是 Delaunay 的网格重放是稳定无操作的(第二次重放翻转数为 0)。
六、仓库内的真实应用:FEM 弹性形状优化
warp.geometry并非孤立模块,它已直接用于仓库的 FEM 示例 warp/examples/fem/example_elastic_shape_optimization.py。该示例在形状优化迭代中实现remesh():每次迭代调用warp.geometry.delaunay_edge_flip改善三角形质量——
num_flips = int( warp.geometry.delaunay_edge_flip( self._vertex_positions, self._tri_vertex_indices, ref_positions=self._initial_positions, # 参考配置保护 ).numpy()[0] )这里特意把self._initial_positions作为ref_positions传入,防止翻转使初始参考配置出现退化三角形;翻转数不为 0 时再重建 FEM 空间与场,并刻意避免重建Trimesh2D对象——因为边翻转只影响内部边,边界结构与投影算子保持有效,而重跑拓扑构建会引入非确定性边序、扰动边界投影算子。这个真实用例同时印证了ref_positions参数的设计动机与“原地修改连接关系”的语义。
七、测试覆盖与验证方法
完整的测试套件位于 warp/tests/geometry/test_delaunay.py,其特点是使用独立的 NumPy 参考实现(_signed_area、_in_circle_det、_edge_map,见该文件 L18-L45)验证结果,而非对照 Warp 自身实现,测试项包括:
- 谓词:
signed_area、in_circle在单位直角三角形上的数值正确性; - 邻接:单条共享边的最小网格、grid 网格上的 round-trip(
triangle_neighbors[n, jn] == t互指)、vertex_count推断与显式传入结果一致、捕获期内必须显式传vertex_count; - 翻转:单条边翻转(翻转数恰为 1、共享边变为垂直对角线)、带抖动 grid 收敛到 Delaunay 且总面积守恒、顶点集合不变、二次翻转为 0(固定点性质)、40×40 大网格压力测试(并发翻转不破坏邻接、无竞态)、剪切网格少轮收敛、已是 Delaunay 的网格保持不变、参考配置退化时拒绝翻转、空网格返回 0、非法参数拒绝且不改变连接、凸多边形扇形网格与星形多边形(非凸域,边界边保持不变、内部边翻转到固定点)、以及完整的 CUDA Graph 捕获-重放一致性测试。
其中“固定点”与“面积/顶点守恒”的断言(如 warp/tests/geometry/test_delaunay.py)是对并行边翻转正确性的强验证:翻转只改变对角线,不改变顶点集合与总覆盖面积。
八、使用注意事项小结
- 务必
import warp.geometry:该模块不会被import warp自动引入(warp/geometry.py); - 网格前提:流形、一致的逆时针绕序;边界边(单三角形共享)永远不会被翻转;
- 原地语义:
indices会被修改,positions保持不变;如需保留原始连接关系请自行拷贝; - 返回值是设备数组:急切模式下用
.numpy()[0]读取;捕获模式下重放后再读; ref_positions的适用场景:当工作网格是某个参考网格的形变(如 FEM 优化迭代)时,用它防止翻转引入参考配置下的退化三角形;- 捕获约束:
tri_tri_adjacency在捕获内必须显式传vertex_count;CUDA 条件图节点要求 Toolkit 与驱动 12.4+;捕获前完成模块加载与设备分配预热。
总而言之,warp.geometry的这次新增(changelog 条目 changelog/1797.added.md)把传统的串行 Delaunay 边翻转流程整体搬上了设备端:从半边区段构建邻接、原子声明并行独立集、位反转优先级加速收敛,到wp.capture_while驱动的零主机同步收敛循环,为需要在模拟/优化迭代中反复改善网格质量的场景(如 FEM 重网格化)提供了一条可直接嵌入 CUDA Graph 的高效路径。
【免费下载链接】warpA Python framework for GPU-accelerated simulation, robotics, and machine learning.项目地址: https://gitcode.com/GitHub_Trending/warp/warp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考