Warp 新增 warp.geometry 模块:基于设备端并行与 CUDA Graph 捕获的 Delaunay 边翻转(delaunay_edge_flip)全解析
2026/9/17 5:59:30 网站建设 项目流程

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_flipfind_triangle_neighbor_edge_indextri_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]:

参数详解

参数类型默认值含义
positionswp.array[wp.vec2](一维)必填vertex_count个顶点的 2D 坐标,不会被修改
indiceswp.array2d[wp.int32],形状(num_tris, 3)必填三角形顶点索引,原地更新为翻转后的连接关系
ref_positionswp.array[wp.vec2]None可选的参考配置顶点坐标。提供后,会在参考配置下产生退化三角形的翻转被拒绝,适用于“工作网格是参考网格的形变且必须保持非退化”的场景
max_passesint1000并行翻转的最大轮数,停止条件之一;必须>= 1
area_epsilonfloat0.0翻转产生的每个三角形所需的最小有向面积,用于防止产生反向(inverted)或薄片(sliver)三角形;必须有限且非负
ref_area_epsilonfloat1.0e-10应用于ref_positions的退化阈值;必须有限且非负

返回值

返回一个位于indices.device上的、形状为(1,)int32设备数组,其中保存被翻转的边总数。注意它不是Python 整数,即使网格为空也总是返回设备端累加器(见 warp/_src/geometry.py 的空网格分支)。在 CUDA Graph 捕获场景下,必须在重放(replay)之后再读取该值。

输入前置条件与校验

  • 网格必须是流形(每条边被 1 或 2 个三角形共享),且三角形具有一致的逆时针绕序(有向面积为正);
  • positionsref_positions必须与indices在同一设备上,否则抛出ValueError
  • indices.ndim != 2indices.shape[1] != 3时抛出ValueError
  • max_passes < 1area_epsilon/ref_area_epsilon非有限或为负时同样抛出ValueError(见 warp/_src/geometry.py)。

这些校验有对应的测试覆盖:warp/tests/geometry/test_delaunay.py 验证了max_passes=0/-1area_epsilon/ref_area_epsilon-1.0naninf时均被拒绝,且被拒绝的调用不会改动输入连接关系。

最小可用示例

以测试中“薄四边形翻转单条边”的用例(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_countNone网格顶点数。为None时内部通过一次设备端归约推断为“最大顶点索引 + 1”;在 CUDA Graph 捕获上下文中必须显式传入,否则抛出RuntimeError
return_neighbor_edge_indicesTrueTrue时额外计算并返回每个邻接三角形中共享边对应的局部边索引;为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 内查找neighbortriangle的局部边索引:返回满足triangle_neighbors[triangle, j] == neighborj,若两者不相邻则返回-1。当tri_tri_adjacencyreturn_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 思路):

  1. _count_vertex_edges:每个线程(一个三角形)将三条半边分别计入其较低端点对应的桶计数(atomic_add);
  2. warp.utils.array_scan:对计数做前缀和得到桶偏移;
  3. _scatter_vertex_edges:将每条半边散射进对应桶,记录较高端点与打包后的半边编号tri * 3 + local_edge
  4. _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)决定,顺序如下:

  1. 链接条件(link condition):翻转把边ab换成对角cd,生成三角形(c, a, d)(c, d, b)。只有当四边形a, d, b, c凸、即两个新三角形均有向面积为正时才继续(_EdgeFlipper._link_condition_ok,warp/_src/geometry.py),否则直接返回False
  2. 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 所述并非精确几何谓词;
  3. 参考配置保护:若提供了ref_positions,还会检查两个新三角形在参考配置下的有向面积绝对值是否小于ref_area_epsilon,若是则拒绝翻转(防止参考网格退化)。

谓词本身的正确性有独立测试验证:warp/tests/geometry/test_delaunay.py 在单位直角三角形上确认signed_area对逆时针返回+0.5、顺时针返回-0.5in_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 捕获使用的五个注意点

  1. vertex_count必须显式传入tri_tri_adjacency:捕获期内不允许None触发的主机回读(会抛RuntimeError);
  2. CUDA 条件图节点需要 Toolkit 与驱动 12.4+:可用wp.is_conditional_graph_supported()检测,不支持时测试会跳过(warp/tests/geometry/test_delaunay.py);
  3. 捕获前预热分配与模块加载:测试在捕获前先跑一次翻转以预热设备分配(radix-sort scratch 等),并调用wp.load_module(warp.geometry, device=device)预加载 kernel,避免捕获区内触发首次分配或编译(warp/tests/geometry/test_delaunay.py);
  4. 捕获只记录不执行:捕获期间网格连接关系保持不变,重放后才生效;
  5. 返回值在重放后才有效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_areain_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)是对并行边翻转正确性的强验证:翻转只改变对角线,不改变顶点集合与总覆盖面积。

八、使用注意事项小结

  1. 务必import warp.geometry:该模块不会被import warp自动引入(warp/geometry.py);
  2. 网格前提:流形、一致的逆时针绕序;边界边(单三角形共享)永远不会被翻转;
  3. 原地语义indices会被修改,positions保持不变;如需保留原始连接关系请自行拷贝;
  4. 返回值是设备数组:急切模式下用.numpy()[0]读取;捕获模式下重放后再读;
  5. ref_positions的适用场景:当工作网格是某个参考网格的形变(如 FEM 优化迭代)时,用它防止翻转引入参考配置下的退化三角形;
  6. 捕获约束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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询