Performance of numpy operations in Blender

This article is a follow-up to this one, where we introduced the foreach_get()/foreach_set() methods and had a quick look at NumPy.

I want to present a small add-on that implements different methods to determine the distance from the active camera to the closest vertex in the active mesh. As we will see, all three methods use different approaches for accessing vertex data and calculating distances, which significantly impacts performance and memory trade-offs.

Naive implementation

What It Does

The NAIVE approach iterates over mesh.vertices using a standard Python loop and calculates distances with pure Python's per-vertex Vector math. When we measure performance it provides us with a baseline that we can compare other methods to.

The relevant code is straight forward, to get the vertex coordinates we loop over all vertices and get the co attribute (a Vector object) and return them as a Python list:

def get_vertex_positions(obj: Object) -> list[Vector]: mesh: Mesh = obj.data # type: ignore return [v.co for v in mesh.vertices]

The code to determine the closest distance is also very straight forward:

def get_closest_vertex_index_to_camera_naive( world_verts: npt.NDArray[np.float32], cam_pos: npt.NDArray[np.float32] ) -> Tuple[int, float | np.floating[Any]]: closest_distance = np.inf closest_index = -1 for vertex_index, vertex_pos in enumerate(world_verts): direction = vertex_pos - cam_pos distance = np.linalg.norm(direction) if distance < closest_distance: closest_distance = distance closest_index = vertex_index return closest_index, closest_distance

Note that the type annotation here mentions numpy NDArray, so that it will be possible to pass them, but in the NAIVE code path we will actually pass just Python lists. If we would pass numpy arrays this code would would also work.

We do use np.linalg.norm() here just to make it possible to accept ndarrays; Would we have gone for a completely naive Python/Blender approach we should have used distance = direction.length instead.

Key Trade-offs

  • Pros: It's the simplest and most readable method, and doesn´t requiry NumPy.
  • Cons: It incurs high per-vertex Python overhead (for attribute access and method calls), making it slow on large meshes.
  • Performance: Acceptable for small meshes (hundreds to low thousands of vertices). It scales poorly for 10,000+ vertices.

FOREACH (mesh.foreach_get / foreach_set)

What It Does

The FOREACH method uses mesh.foreach_get to perform a bulk copy of vertex coordinates into a flat, numeric buffer (typically a NumPy array). However, in the provided code path, the closest vertex is still found using a Python loop, meaning only the data transfer is accelerated (but the values are stored in an ndarray; that's why we accept those in the get_closest_vertex_index_to_camera_naive() function)

def get_vertex_positions_np(obj: Object) -> npt.NDArray[np.float32]: mesh: Mesh = obj.data # type: ignore coords = np.empty(len(mesh.vertices) * 3, dtype=np.float32) mesh.vertices.foreach_get("co", coords) return coords.reshape(-1, 3)

Key Trade-offs

  • Pros: The bulk copy significantly reduces Python attribute overhead for reading vertex data, making it much faster than NAIVE for data transfer.
  • Cons: If the distance calculation remains in a Python loop, you still keep the performance-limiting Python-loop overhead. This method also requires NumPy.
  • Performance: A good improvement over NAIVE for medium meshes, but it doesn't achieve the speed of fully vectorized numerical processing.

BROADCAST (NumPy Vectorized)

What It Does

The BROADCAST method also reads vertex coordinates into a NumPy array using foreach_get. Crucially, it then performs distance calculations using vectorized NumPy operations. This means no Python per-vertex loop is used; instead, np.argmin() finds the closest index.

def get_closest_vertex_index_to_camera( world_verts: npt.NDArray[np.float32], cam_pos: npt.NDArray[np.float32] ) -> Tuple[int, float | np.floating[Any]]: dists = np.linalg.norm(world_verts - cam_pos, axis=1) i = np.argmin(dists) return i, dists[i]

This function is not only a lot shorter without any Python loop in sight, but it also nicely shows the power of numpy.

world_verts is a N x 3 array, and cam_pos a 1 x 3 array. Numpy's broadcasting rules interpret this to mean we want to subtract the camera position from each vertex position, result in an N x 1 list of distances.

np.argmin() then takes this list of distance and returns the index of the smallest distance.

All this happens inside optimized C/C++ code, sidestepping the Python loop performance penalty completely.

Key Trade-offs

  • Pros: Distances are computed in highly optimized C code (via BLAS/NumPy internals), resulting in minimal Python overhead. This is the fastest approach for large meshes.
  • Cons: It requires extra memory for NumPy arrays and temporary arrays (verts_h, world_verts, dists). It also requires NumPy and careful management of array shapes and data types.
  • Performance: Best for large meshes (tens of thousands of vertices and up). While array allocation overhead may make it slightly slower for tiny meshes, it's generally still acceptable.

Performance results

If we look at the measured performance we see a clear linear dependance on the number of vertices for all methods:

Performance graph

Note that both axes are logarithmic to make it possible to graph the results of many orders of magnitude.

The naive method is too slow to go beyond 1 million vertices: The last point at 1.5M verts already takes more than 4 seconds.

The method that uses foreach_get() as about twice as fast, but that wouldn´t get us anywhere near 10 million vertices.

However, the method that uses numpy operations to calculate distances and figure out what the minimum distance is, is a whole lot faster. The last point on the yellow line clocks in at about 1 second for 25 million vertices, over a 60x improvement on the naive method.

The actual numbers may of course be different on your computer but the overall trend is pretty clear: using numpy is the way to go.

Practical Guidance & Rule of Thumb

Since the code isn´t all that more complicated, always use all the available nummpy functionality. Only in those cases where you are very contrained by memory you might consider the other approaches because the extra buffer for the foreach_get() method and any emporary arrays that numpy creates can add up when working with large numbers of vertices.

Efficient Vertex Manipulation in Blender with NumPy

When manipulating large numbers of vertices in Blender via Python, a naive approach — relying on standard Python iteration (loops) and individual property assignment — is computationally slow. Blender's Python layer introduces significant overhead when repeatedly crossing the boundary between the Python interpreter and the core C/C++ data structures for each vertex.

The performance bottleneck can be overcome by using NumPy to process the data in bulk using vectorized operations. Because NumPy is bundled with Blender, there is no need to install anything extra; your add-on can simple import it.

The numpy based approach involves three main steps: data export, array processing, and data import.

Bulk data transfer: foreach_get() and foreach_set()

Instead of looping through mesh.vertices[i].co one at a time, Blender's Python API provides high-speed methods for transferring entire blocks of data from and to property collections: foreach_get() and foreach_set().

These methods directly copy data between Blender's internal data structures (like vertex coordinates, normals, or custom properties) and a flat, contiguous NumPy ndarray. This minimizes the number of expensive function calls across the Python/C boundary, turning thousands of slow operations into one or two very fast memory copies.

The foreach_get() and foreach_set() methods are available for any property collection, and allow access to any property that has a 'basic' type (i.e. a bool, int or float).

# Assuming 'mesh' is a Blender mesh data block import numpy as np # bundled with Blender, so no need to install it # Export: Get all vertex coordinates into a flat NumPy array # 'co' are the vertex coordinates (x, y, z), so the array size is N_verts * 3 verts_co = np.empty(len(mesh.vertices) * 3, dtype=np.float32) mesh.vertices.foreach_get("co", verts_co) # The array is now a flat 1D array; reshape for 3D processing verts_co = verts_co.reshape((-1, 3))

Note that the NumPy ndarray needs to be allocated before we can use a call to foreach_get(). There is no need to zero out the contents of this new array, so we can us np.empty() to allocate space saving some time by omitting unnecessary initialization.

Vectorized processing with NumPy

Once the vertex coordinates are in a NumPy ndarray, all processing should be performed using NumPy's optimized C-backed functions.

NumPy Operations

NumPy executes operations on the entire array at once, often using highly optimized C implementations, which is vastly faster than interpreted Python loops.

Example: Translating all vertices

# Define a translation vector translation_vector = np.array([1.0, 0.5, 0.0]) # Perform the addition on all vertices simultaneously # This is where the speed comes from verts_co += translation_vector

The Power of Broadcasting

Broadcasting is a fundamental NumPy mechanism that automatically handles operations between arrays of different but compatible shapes. It allows the smaller array (the translation vector [1.0, 0.5, 0.0]) to be conceptually "stretched" or "broadcast" across the larger array (verts_co), eliminating the need to explicitly write a loop to apply the translation to every single vertex row.

It is no exaggeration to state that when using NumPy, any explicit loop left in your Python code means your are probably not doing things right.

NumPy performs the operation by aligning the shapes from the trailing dimensions, effectively applying the 1x3 translation vector shown in the example to every row in the Nx3 array of vertex coordinates.

Importing data back

After all modifications are complete, the resulting NumPy array is copied back into the Blender mesh data using foreach_set().

# Import: Flatten the processed array back to 1D verts_co = verts_co.reshape(-1) # Set the data back into the mesh mesh.vertices.foreach_set("co", verts_co) # Update the mesh data for Blender to display the changes mesh.update()

By utilizing foreach_get()/foreach_set(), and NumPy's vectorized operations, operations involving hundreds of thousands or even millions of vertices become practical. In a future article I will explore this some more with a real world example and some performance data.