Skip to the content
FixTheRobot Explore Robotics / UAV / Module 5 / M5-T17

UAV Systems Engineering · Module 5

M5-T17: 3-D path planning on voxel maps

Planning in the air is the same search as on the ground with one more axis, and that axis costs a factor of eight in cells every time you halve the cell size.

  • Lesson 17 of 17
  • UAV Guidance, Navigation and Control (GNC)
  • Established engineering
  • About 11 minutes to read

A UAV moves in three dimensions, so its planner searches a three-dimensional map. The algorithm is the A* of the previous planning lesson, but the neighbours, the step costs, the heuristic, the memory and the safety margins all change with the extra axis. This lesson builds the 3-D version, shows what the heuristic buys, and explains how the resolution of the map and the inflation of obstacles decide both the cost of the search and the safety of the result.

Learning objectives

After this lesson, you should be able to:

  • Describe a 3-D voxel map and compare a dense grid, an octree and a distance field as map representations.
  • Write the 26-neighbour step costs and the 3-D octile heuristic, and explain why the heuristic is admissible and consistent.
  • Choose the map resolution and the obstacle inflation from the vehicle size, and predict how the search cost scales with the cell size.
  • Shorten a grid path with line-of-sight checks and state what the back end of the planner must still do.

Conceptual explanation

Established A* (Hart, Nilsson and Raphael, 1968), occupancy grids and octrees (for example OctoMap) and any-angle shortcuts are standard techniques. The map, the numbers of cells and expansions and the margins below are illustrative values from the example in this lesson, not properties of a particular vehicle or sensor.

A voxel map divides space into cubes of side Δ\Delta and stores, for each cube, whether it is free, occupied or unknown. It is the three-dimensional occupancy grid that LiDAR or depth-camera mapping produces (Module 4). A planner treats each free cube as a node and connects it to its neighbours.

In 2-D a cell has 8 neighbours. In 3-D it has 26: 6 across faces, 12 across edges and 8 across corners, with step lengths of 1, 2\sqrt{2} and 3\sqrt{3} cells. That changes three things in the planner:

  • Step costs are the Euclidean lengths of the moves, so a corner move costs 3\sqrt{3}, not 1.
  • The heuristic must use all three axes: the 3-D octile distance below.
  • Memory and time grow with the cube of the resolution. A map ten times finer has a thousand times more cells.

The map can be stored in three common ways:

Three ways to store a 3-D map for planning.
RepresentationIdeaGood forCost
Dense voxel gridOne value per cube in a 3-D arrayFast lookups and simple planners; local maps around the vehicleMemory ∝ volume ÷ Δ3\Delta ^{3}, even for empty space
Octree (for example OctoMap)Recursively split a cube into eight; keep large cubes where space is uniformLarge environments at fine resolution; probabilistic occupancyPointer-based, slower to query than an array
Distance field (ESDF)Store the distance from each cell to the nearest obstacleClearance checks, gradient-based smoothing, safe-corridor constructionMust be updated as the map changes
3-D planning pipelineA 3-D map is inflated by the vehicle radius and a margin, a 26-connected search finds a collision-free sequence of cells, a line-of-sight shortcut removes the grid’s zig-zags, and a safe-corridor and polynomial back end turns the few remaining waypoints into a smooth trajectory.3-D mapvoxels, octree, ESDFInflateradius + marginSearch26-connected A*Shortcutline of sightSmoothcorridor + min-snapThe grid search finds the topology; shortcutting and smoothing make it flyable.
The 3-D planning pipeline. The grid search finds the topology of the path; the later stages make it smooth.

Two decisions in the map decide whether the plan is safe. Unknown space is usually treated as an obstacle when flying in an unmapped environment, and as free only when the mission is exploration with a reactive layer behind the planner. Altitude is not free: the vehicle has a ceiling and a floor, climbing costs energy, and an optimal path may be allowed to go over an obstacle, which a 2-D planner could never do.

Animation of two searches on the same three-dimensional voxel map drawn in isometric view. The map has a low wall, a full-height wall with a window, and a pillar; the start is near one corner and the goal on the far side of both walls. On the left A* with the three-dimensional octile heuristic expands cells in order; on the right Dijkstra search, which has no heuristic, expands cells in all directions. Both searches advance together with the same number of expansions shown (the number grows on a logarithmic schedule so that the early and late stages are both visible), and counters show how many cells each has expanded. When A* finds the goal its path is drawn in green; Dijkstra needs many more expansions to reach the same optimal cost.
Both searches use the 26-connected grid with step costs 1, 2\sqrt{2} and 3\sqrt{3}. With an admissible, consistent heuristic A* returns the same optimal path cost as Dijkstra but expands fewer cells; the saving depends on the map and is smaller in cluttered scenes. The map is a 16 × 16 × 8 voxel grid made up for this lesson (the same map as the code example) and the cell counts are computed by running both searches. (Looping, about 6 seconds; drawn and computed for this course with illustrative values.)

Engineering intuition

Picture a three-dimensional maze made of cubes. A* explores like a flood of water that is pulled towards the goal: it expands the cells that look cheapest overall, the cost so far plus an optimistic estimate of the cost to go. With no estimate (Dijkstra’s algorithm) the flood spreads evenly in every direction, including away from the goal, and fills most of the free space before it arrives. The estimate costs nothing to compute and, in this lesson’s example, cuts the work by more than ten times.

The grid path is the cheapest sequence of cells, not the shortest curve. It turns in multiples of 45∘45^{\circ} and often zig-zags. A shortcut that checks straight lines between waypoints removes most of that, but a straight line can pass close to a corner of an obstacle, so the check has to use the inflated map. And the finer the grid, the better the path and the longer the search, so the resolution is a trade between path quality and planning time, set by how fast the vehicle must replan.

Mathematical formulation

Cost and heuristic

A* expands the node with the smallest estimated total cost, the cost accumulated from the start plus an estimate of the remaining cost:

A* evaluation function
f(n)=g(n)+h(n)f(n) = g(n) + h(n)
g(n) is the cost of the best path found so far to cell n; h(n) estimates the cost from n to the goal.

Moving between two neighbouring cell centres costs their distance in cells:

Step cost on the 26-connected grid
c(u,v)=∥v−u∥∈{1, 2, 3}c(u, v) = \lVert v - u\rVert \in \{1,\ \sqrt{2},\ \sqrt{3}\}
Across a face, an edge or a corner of the cube.

On an empty grid the cheapest way to cover offsets of d1≤d2≤d3d_{1}\le d_{2}\le d_{3} cells along the three axes (sorted) is d1d_{1} corner moves, then d2−d1d_{2}-d_{1} edge moves, then d3−d2d_{3}-d_{2} straight moves. Its cost is the 3-D octile distance:

3-D octile heuristic
h(n)=3 d1+2 (d2−d1)+(d3−d2)=(3−2) d1+(2−1) d2+d3\begin{aligned} h(n) &= \sqrt{3}\,d_1 + \sqrt{2}\,(d_2 - d_1) + (d_3 - d_2)\\ &= (\sqrt{3} - \sqrt{2})\,d_1 + (\sqrt{2} - 1)\,d_2 + d_3 \end{aligned}
d₁ ≤ d₂ ≤ d₃ are the absolute offsets to the goal along the three axes, in cells.

Obstacles can only make the true cost larger, so hh never overestimates (it is admissible). It also satisfies h(u)≤c(u, v)+h(v)h(u)\le c(u,\,v)+h(v) for every move (it is consistent), so A* never needs to reopen a cell and returns an optimal path. A weighted version, f=g+w⋅hf=g+w\cdot h with w>1w>1, expands fewer cells at the price of a path at most ww times longer than optimal.

Resolution, memory and safety

For a region of size Lx×Ly×LzL_{x}\times L_{y}\times L_{z} the number of cells, and therefore the memory of a dense grid, scales with the inverse cube of the cell size:

Number of cells
N=Lx Ly LzΔ3N = \frac{L_x\,L_y\,L_z}{\Delta^{3}}
Halving Δ multiplies N by 8.

For example a 100 m × 100 m × 30 m region at Δ=0.5 m\Delta =0.5\,\mathrm{m} has 200×200×60=2.4×106200\times200\times60 = 2.4\times10^{6} cells (2.4 MB at one byte per cell), and at Δ=0.1 m\Delta =0.1\,\mathrm{m} it has 3×1083\times10^{8} cells (300 MB). Because the search expands cells on a surface that also grows with resolution, the work grows by a similar factor per halving.

The vehicle is not a point. An obstacle surface can lie anywhere inside an occupied cell, and a path through free cell centres can pass anywhere within half a cell of them. A conservative inflation radius is therefore

Obstacle inflation radius
rinfl=rvehicle+m+32 Δr_{\text{infl}} = r_{\text{vehicle}} + m + \frac{\sqrt{3}}{2}\,\Delta
Vehicle radius, a safety margin m (tracking error, localisation uncertainty), and the half-diagonal of a cell.
Δ\Delta
cell size (m).
nn, uu, vv
cells of the grid.
gg, hh, ff
cost so far, heuristic estimate, and their sum.
d1≤d2≤d3d_{1}\le d_{2}\le d_{3}
sorted absolute offsets to the goal, in cells.
rvehicler_{\text{vehicle}}, mm
vehicle radius and safety margin (m).

Architecture and data flow

The planner reads the current voxel map (and its distance field) from the mapping module (Module 4) and the vehicle state from the estimator. It inflates the map, searches from the current position to the goal, shortens the path with line-of-sight checks on the inflated map, and passes the few remaining waypoints to the trajectory generator (lesson M5-T9), which builds a smooth polynomial through them within the velocity and acceleration limits (lesson M5-T12). The tracking controller (lesson M5-T14) flies the result. When the map changes, a monitor checks the remaining trajectory for collision and triggers a replan; the sensing and planning latency set how fast the vehicle may fly (lesson M5-T13).

Real-world UAV applications

  • Global planning for a UAV that flies through a mapped building, tunnel or forest.
  • Replanning around newly sensed obstacles in a local voxel map around the vehicle.
  • Inspection paths that go over, around or through openings in structures.
  • Generating a topologically correct initial path for a smoothing or corridor-based optimiser.

Implementation example

This runs as shown. It builds a 16 × 16 × 8 voxel map with a low wall, a full-height wall with a window and a pillar, and runs 26-connected A* with the 3-D octile heuristic. The tests check that A* finds the same optimal cost as Dijkstra’s algorithm with far fewer expansions, that the path is collision-free and never shorter than the straight line, that the heuristic is consistent, that inflation keeps the clearance, that the shortcut path is shorter and clear, and that halving the cell size multiplies the cells by eight and the work by much more.

import heapq, math
import numpy as np

def make_world(scale=1):
    """A 16 x 16 x 8 voxel map (times `scale` per axis): a low wall, a full-height wall with a window, and a pillar."""
    occ = np.zeros((16, 16, 8), bool)
    occ[5:7, 0:11, 0:5] = True          # low wall: go over it (z >= 5) or around it (y >= 11)
    occ[10:12, 5:16, :] = True          # full-height wall ...
    occ[10:12, 8:11, 2:4] = False       # ... with a window (y 8..10, z 2..3)
    occ[3:5, 8:10, 0:4] = True          # pillar
    return np.repeat(np.repeat(np.repeat(occ, scale, 0), scale, 1), scale, 2)

N26 = [(i, j, k) for i in (-1, 0, 1) for j in (-1, 0, 1) for k in (-1, 0, 1) if (i, j, k) != (0, 0, 0)]

def h_oct3(a, b):
    """3-D octile distance in cells: exact cost of the best path on an empty 26-connected grid."""
    d = sorted(abs(a[i] - b[i]) for i in range(3))
    return (math.sqrt(3) - math.sqrt(2)) * d[0] + (math.sqrt(2) - 1) * d[1] + d[2]

def inflate(occ, r):
    """Block every cell whose centre is within r cells of an occupied cell (spherical dilation)."""
    out = occ.copy(); R = int(math.ceil(r))
    for dx in range(-R, R + 1):
        for dy in range(-R, R + 1):
            for dz in range(-R, R + 1):
                if dx * dx + dy * dy + dz * dz <= r * r and (dx, dy, dz) != (0, 0, 0):
                    src = occ[max(0, -dx):occ.shape[0] - max(0, dx), max(0, -dy):occ.shape[1] - max(0, dy), max(0, -dz):occ.shape[2] - max(0, dz)]
                    out[max(0, dx):occ.shape[0] - max(0, -dx), max(0, dy):occ.shape[1] - max(0, -dy), max(0, dz):occ.shape[2] - max(0, -dz)] |= src
    return out

def astar(occ, s, g, use_h=True):
    """A* on a 26-connected grid. Returns (path, cost, number of expanded cells)."""
    shape = occ.shape; openq = [(h_oct3(s, g) if use_h else 0.0, 0.0, s)]; gs = {s: 0.0}; parent = {s: None}; closed = set()
    while openq:
        _, gc, u = heapq.heappop(openq)
        if u in closed: continue
        closed.add(u)
        if u == g: break
        for d in N26:
            v = (u[0] + d[0], u[1] + d[1], u[2] + d[2])
            if not all(0 <= v[i] < shape[i] for i in range(3)) or occ[v] or v in closed: continue
            ng = gc + math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)          # 1, sqrt 2 or sqrt 3
            if ng < gs.get(v, 1e18): gs[v] = ng; parent[v] = u; heapq.heappush(openq, (ng + (h_oct3(v, g) if use_h else 0.0), ng, v))
    if g not in parent: return None, None, len(closed)
    path = []; u = g
    while u is not None: path.append(u); u = parent[u]
    return path[::-1], gs[g], len(closed)

def clear_segment(occ, a, b, step=0.25):
    """Line of sight between two cell centres: sample the segment and test the voxel under every sample."""
    a, b = np.array(a, float) + 0.5, np.array(b, float) + 0.5; n = max(1, int(np.linalg.norm(b - a) / step))
    return all(not occ[tuple(np.floor(a + (b - a) * t / n).astype(int))] for t in range(n + 1))

def shortcut(occ, path):
    """Greedy any-angle shortcut: from each waypoint jump to the farthest waypoint that is still in line of sight."""
    out = [path[0]]; i = 0
    while i < len(path) - 1:
        j = len(path) - 1
        while j > i + 1 and not clear_segment(occ, path[i], path[j]): j -= 1
        out.append(path[j]); i = j
    return out

length = lambda pts: sum(math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))

occ = make_world(); s, g = (1, 2, 1), (14, 13, 2)
path, cost, n_a = astar(occ, s, g)
p0, cost0, n_d = astar(occ, s, g, use_h=False)                       # Dijkstra: the same search with h = 0
print(f"A*: cost {cost:.2f}, {n_a} expansions;  Dijkstra: cost {cost0:.2f}, {n_d} expansions")
assert abs(cost - cost0) < 1e-9 and n_a < n_d                         # same optimal cost, fewer expansions
assert not any(occ[c] for c in path) and all(max(abs(path[i + 1][k] - path[i][k]) for k in range(3)) == 1 for i in range(len(path) - 1))
assert cost >= math.dist(s, g) - 1e-9                                 # never shorter than the straight line

rs = np.random.default_rng(1); free = [tuple(c) for c in np.argwhere(~occ)]
for _ in range(2000):                                                 # consistency: h(u) <= c(u, v) + h(v) for every neighbour
    u = free[rs.integers(len(free))]; d = N26[rs.integers(26)]; v = (u[0] + d[0], u[1] + d[1], u[2] + d[2])
    assert h_oct3(u, g) <= math.sqrt(sum(x * x for x in d)) + h_oct3(v, g) + 1e-9

r = 1.5                                                               # vehicle radius plus margin, in cells
occ_i = inflate(occ, r); pi, ci, _ = astar(occ_i, s, g)
obst = np.argwhere(occ); clearance = min(np.min(np.linalg.norm(obst - np.array(c), axis=1)) for c in pi)
print(f"inflated by {r} cells: cost {ci:.2f}, minimum clearance {clearance:.2f} cells")
assert ci >= cost - 1e-9 and clearance > r                            # a larger robot never finds a shorter path and keeps its clearance

sc = shortcut(occ_i, pi)
print(f"waypoints {len(pi)} -> {len(sc)}, length {length(pi):.2f} -> {length(sc):.2f} cells")
assert len(sc) < len(pi) and length(sc) <= length(pi) + 1e-9
assert all(clear_segment(occ_i, sc[i], sc[i + 1]) for i in range(len(sc) - 1))

fine = make_world(2); sf, gf = tuple(2 * x for x in s), tuple(2 * x for x in g)
_, cf, n_f = astar(fine, sf, gf)
print(f"cells {occ.size} -> {fine.size} (x{fine.size // occ.size});  A* expansions {n_a} -> {n_f}")
assert fine.size == 8 * occ.size and n_f > 3 * n_a                    # halving the cell size multiplies the cells by 8 and the work by a lot
A 3-D voxel planner with five checks. The map and the numbers are illustrative.

The animation in the concept section runs the same two searches on the same map.

Common engineering mistakes

Using the 2-D octile heuristic or the Manhattan distance in 3-D

A heuristic that overestimates breaks optimality; one that ignores an axis gives up the speed-up. Use the 3-D octile distance for a 26-connected grid.

Planning through cell centres with no inflation

The path touches obstacle corners. Inflate by the vehicle radius, a margin and the half-diagonal of a cell, and check shortcuts on the inflated map.

Treating unknown space as free

The plan flies into places the sensors have not seen. In an unmapped environment treat unknown as occupied, or keep a reactive layer that can stop the vehicle.

Choosing the finest resolution the sensor can give

Cells and search work grow with the cube of the resolution, so the planner is too slow to replan. Choose the coarsest Δ\Delta that keeps narrow gaps open.

Flying the grid path directly

It turns in multiples of 45∘45^{\circ} and zig-zags, which demands acceleration the vehicle does not have. Shorten it and pass it through a trajectory generator.

Failure modes

Failure modes, symptoms and causes
Failure modeHow it shows upLikely cause
Planner finds no pathSearch exhausts the map and returns nothingInflation closed a narrow opening, or the start or goal lies inside the inflated obstacles
Plan takes too long to replanPlanning time exceeds the replanning periodMap too fine for the region, or the search area is not limited to a local window
Vehicle clips an obstacle cornerContact or near miss at a cornerInflation without the half-diagonal term, tracking error larger than the margin, or a shortcut checked on the uninflated map
Path climbs and descends unnecessarilyAltitude oscillates along the routeStep costs treat vertical and horizontal moves alike with no penalty or limit on climbing

Debugging approach

  1. Draw the inflated map next to the original and check that known openings are still open.
  2. Run Dijkstra (h=0)(h=0) and compare the cost with A*: any difference means the heuristic is not admissible.
  3. Count expansions as the cell size changes and compare with the factor of eight per halving.
  4. Check each shortcut segment against the inflated map, not the raw map, and log the minimum clearance along the path.

Practical exercises

  1. Show that the 3-D octile distance equals the cost of the best path on an empty 26-connected grid, by counting moves for the offset (3, 5, 9).
  2. Add a vertical-move penalty of 1.5 to the step cost and observe the change in the path; what happens to the admissibility of the heuristic?
  3. Change the weight to w=1.5w=1.5 and plot the expansions and the path cost against ww; compare with the bound of ww times the optimal cost.
  4. Downsample the map by two with an any-occupied rule and plan again; find a window size at which the path is lost.

Interview questions

Write an answer in your own words, then reveal the model answer. Nothing is sent anywhere.

Question 1

What changes when you move A* from 2-D to 3-D?

Model answer

Each cell has 26 neighbours instead of 8, with step costs of 1, 2\sqrt{2} and 3\sqrt{3}, the heuristic uses all three axes (the 3-D octile distance), and memory and search effort grow with the cube of the resolution, so the map resolution becomes a main design choice.

Question 2

Why is the 3-D octile distance an admissible heuristic?

Model answer

It is the exact cost of the best path on an empty grid with the same moves, and obstacles can only add cost, so it never overestimates the true cost. It is also consistent, so A* returns an optimal path without reopening cells.

Question 3

Why inflate by more than the vehicle radius?

Model answer

An obstacle surface can lie anywhere inside an occupied cell and a path through cell centres can deviate by up to half a cell diagonal, so the clearance must also cover the cell resolution and the vehicle’s tracking and localisation error. The radius alone would leave no margin.

References and further reading

Primary papers, official documentation and standards. Each claim above that is specific to a tool or version points here.

What’s next

Mark this lesson

Saved in this browser and synced to your account when you are signed in.

Practice this lesson

M5-L6: Plan a path in a 3-D voxel map

Implement 26-connected A*, inflation and shortcuts, and find the largest vehicle that fits.

Next lesson

M6-T1: Introduction to PX4

Describe what PX4 is, its modular flight stack and where it sits between hardware, middleware and companion software.