Skip to content

Concepts

Four ideas shape every function in triwarp. None of them are optional design flourishes — each one is a constraint the whole library is built against, and understanding them up front saves having to reverse-engineer them from a confusing signature later.

Arrays in, arrays out

The core API is free functions over warp.array buffers. There is no mandatory mesh object and no hidden state:

vertices, faces = tw.creation.icosphere(subdivisions=3)
areas = tw.triangles.face_areas(vertices, faces)  # a plain wp.array[wp.float32], nothing else

A mesh is always the same two things — a wp.array[wp.vec3] of vertex positions and a flat wp.array[wp.int32] of length 3 * n_faces — never a (n_faces, 3) array and never an object with methods. That flat layout is deliberate: it's the shape a Warp kernel indexes without a stride computation, and it's the one convention every function in the package agrees on, so a buffer produced by one function is always a valid argument to the next.

The optional Trimesh class is a thin wrapper around exactly this pair, with derived quantities (normals, adjacency, boundary loops) computed lazily and cached on first access. It exists for convenience, not because the free functions need it — every one of Trimesh's properties is also a public function you can call directly on raw arrays.

Why not NumPy? A wp.array is a real device buffer — on CUDA, an actual allocation on the GPU; the round trip through .numpy() is a real host readback with a real cost. Passing NumPy arrays as the primary interface would mean paying that cost at every function boundary rather than only where a caller actually needs the values back on the host (to print them, save them, or hand them to a plotting library). wp.array(numpy_array, dtype=...) and warp_array.numpy() are the two directions across that boundary; reach for them exactly as often as your pipeline needs data on the host and no more.

The device follows the data

There is no global device switch. Every kernel launch and every allocation inherits the device of its own input arrays:

import warp as wp

cpu_vertices, cpu_faces = tw.creation.icosphere(subdivisions=2, device="cpu")
gpu_vertices, gpu_faces = tw.creation.icosphere(subdivisions=2, device="cuda:0")

tw.triangles.face_areas(cpu_vertices, cpu_faces)  # runs on the CPU backend
tw.triangles.face_areas(gpu_vertices, gpu_faces)  # runs on cuda:0

A function that allocates a new array without being given one to place it on (a primitive constructor like tw.creation.icosphere) takes a device= keyword and defaults to Warp's current device — wp.set_device("cuda:0") (or a wp.ScopedDevice) sets that default for a whole block of code, same as any other Warp program.

This is what makes chaining functions cheap: a pipeline of ten calls with no explicit device= anywhere runs entirely on one device, because each function's output already carries the device its input arrived on, and the next call reads it from there.

Host syncs are budgeted

A device-to-host readback (.numpy(), or reading a single scalar off a device array) is a real synchronization point — the GPU has to finish everything queued before it, and the value has to physically cross the PCIe bus. Wrappers avoid these wherever the computation doesn't need one, and where one genuinely is unavoidable (typically because a buffer's size depends on a value only the device knows, like how many triangles survived a filter), the function's docstring says so.

Some functions expose a keyword that lets a caller skip the inference behind such a readback, when the caller already knows the bound:

# Without n_vertices, the function reads back a count to size an internal table.
adjacency = tw.adjacency.face_adjacency(faces)

# If you already know how many vertices the mesh has, pass it and skip that readback.
adjacency = tw.adjacency.face_adjacency(faces, n_vertices=vertices.shape[0])

This matters most inside a loop — a saved readback is trivial once, and adds up over a thousand iterations of a solver or a remeshing pass.

Measured, not assumed

Every public module has both a test file, comparing its output against an established CPU geometry-processing library (trimesh, libigl, Open3D, MeshLab, potpourri3d, PyTorch3D, and others — see triwarp.validation-style parity in the test suite), and a benchmark file. A performance change lands only with a before/after measurement behind it, and a correctness change is asserted on actual values, never on shape or "did it run" alone. The Performance page and Benchmarks in the repository are where that discipline is visible from the outside — every number quoted there is reproducible with the same commands the library's own test suite uses.

Where to next