3D VOXEL PATHFINDING · UNITY 6

NavMesh assumes
you have a floor.

My creature wanted to walk on the ceiling. So I threw out the floor assumption entirely, voxelised the whole volume, and squeezed 64 walkability flags into a single 64-bit integer so the memory wouldn't be absurd.

ENGINEUnity 6
LANGUAGEC#
FOCUSTooling · A* · Jobs · Burst
26 NEIGHBOURS · NO FLOOR REQUIRED
// 01 · the problem with floors

Down is not a universal constant.

NavMesh is great, right up until your character stops agreeing that the ground is beneath it. It bakes surfaces you can stand on, which quietly bakes in the idea that there's a single "up". Spiders, climbers, fliers and swimmers all break that assumption immediately.

I could have hacked around it — multiple NavMeshes at different orientations, stitched together. People do. But it's a workaround for a model that just doesn't fit, so I wanted to build the thing that treats the world as a volume from the start.

The catch: a volume has vastly more places to be than a surface does. Making that practical at runtime, rather than merely correct, is where most of the actual work went.

// 02 · how it fits together

From geometry to a usable path.

01Scene GeometryColliders define the environment
→
02Voxel BakeSamples the 3D volume
→
03Voxel GridStores walkable states
→
04A* JobSearches the 3D grid
→
05AgentFollows the resulting path

Baking converts the physical scene into a compact navigable representation. Pathfinding then operates on that representation inside a Burst-compiled Job.

// 03 · chopping the world into cubes

A 3D grid instead of a navigation mesh.

The bake walks the volume as a 3D grid and asks Unity Physics, for every single voxel, "could the agent be here?" It is exactly as brute-force as it sounds, which is why it happens once at bake time and never again at runtime.

A voxel passes if there's room for the agent and it satisfies a maximum distance-from-wall. That second condition is the one that makes this useful rather than generic: a ceiling-crawler needs to stay stuck to surfaces, so the empty middle of a room should be unwalkable for it even though there's plenty of space there.

I built this for things that climb, not for things that fly. But the constraint is just a number, and turning it off should leave you with a grid full of open air — so a flier would probably fall out of it for free. I haven't tried. Now I want to.

VOXEL SIZE

Smaller voxels increase spatial resolution and allow tighter paths around geometry, but also increase bake time and memory usage.

WALL DISTANCE

A configurable maximum distance from nearby geometry controls how closely an agent can stay to a wall or other navigable surface.

// 04 · the bit-packing trick

64 voxels inside one number.

This is my favourite part of the whole tool. A 3D grid grows cubically, and the naive version stores one bool per voxel — which in C# means a whole byte to hold one bit of information. Eight times more memory than the data actually needs, for a structure that's already the thing most likely to blow your budget.

So: each coarse cell covers a 4 × 4 × 4 block of fine voxels. That's 64 yes/no answers. A ulong is 64 bits. They fit exactly, which is one of those coincidences that feels like the universe giving you permission.

4 × 4 × 464 sub-voxelsOne coarse grid cell
→
64 BITS1 × ulongOne bit per fine voxel
→
COMPACTDense storageLess memory per voxel state

The cost is that reading a voxel is now a shift and a mask instead of an array index. Worth it, and the whole coarse cell lands in cache in one go, which the search is quietly grateful for.

Here's the bake side of it. Walk the 64 sub-voxels of one coarse cell, ask physics two questions about each, and drop the answer into bit i of a ulong. Most voxels are floating in open air, so "is anything even near me?" goes first and throws most of them out before the second question gets asked.

DetectCollisionC# · once per cell · bake time
private ulong DetectCollision(int3 position)
{
    ulong result = 0;
    for (int i = 0; i < PathfindingHolder.DATASTRUCTSIZE; i++)
    {
        int3 localPosition = position * PathfindingHolder.SUBDIVIDEBOX
                             + new int3(i % PathfindingHolder.SUBDIVIDEBOX,
                                        (i / PathfindingHolder.SUBDIVIDEBOX) % PathfindingHolder.SUBDIVIDEBOX,
                                        i / (PathfindingHolder.SUBDIVIDEBOX * PathfindingHolder.SUBDIVIDEBOX));

        float3 worldPosition = PathfindingUtilities.FindWorldPosition(localPosition, holder);
        // not too far from something
        bool notTooFar = Physics.CheckBox(worldPosition, Vector3.one * holder._maxDistanceFromWall / 2,
                                          Quaternion.identity, CollisionMask, QueryTriggerInteraction.Ignore);
        if (!notTooFar) continue;
        // not inside of something
        bool notInside = !Physics.CheckBox(worldPosition, Vector3.one * holder._voxelSize / 2,
                                           Quaternion.identity, CollisionMask, QueryTriggerInteraction.Ignore);
        if (notInside) result |= 1ul << i;
    }
    return result;
}

i → (x, y, z) is just base-4 digits: i % 4, (i / 4) % 4, i / 16.

// 05 · the search itself

A* running directly in 3D.

A* itself is the least interesting thing here — it's the same algorithm everyone knows. What makes it fast or slow is the open set. Sorting a list every iteration is the classic beginner move, so it's a hand-rolled binary min-heap instead, inside a Burst-compiled Job where managed collections aren't invited anyway.

Every voxel has 26 neighbours in 3D, not 4 or 6. All of them are searched, so an agent can cut a proper diagonal through a room instead of doing that unmistakable staircase shuffle.

Here's the heap. It's built on a NativeList so it can live inside a Burst job. Instead of swapping a node with its parent at every level, it slides a hole up or down and writes the node exactly once, when it finally lands.

MinHeapC# · Burst · the open set
[BurstCompile]
public static class MinHeap
{
    public static void Push(ref NativeList<PathNode> heap, in PathNode node)
    {
        int i = heap.Length;
        heap.Add(node);

        while (i > 0)
        {
            int parent = (i - 1) >> 1;
            if (heap[parent].total <= node.total)
                break;

            heap[i] = heap[parent];
            i = parent;
        }

        heap[i] = node;
    }

    public static PathNode Pop(ref NativeList<PathNode> heap)
    {
        PathNode root = heap[0];
        int last = heap.Length - 1;
        PathNode node = heap[last];
        heap.RemoveAt(last);

        if (last > 0)
        {
            int i = 0;
            int half = last >> 1;

            while (i < half)
            {
                int left = (i << 1) + 1;
                int right = left + 1;

                int smallest = (right < last && heap[right].total < heap[left].total)
                    ? right
                    : left;

                if (heap[smallest].total >= node.total)
                    break;

                heap[i] = heap[smallest];
                i = smallest;
            }

            heap[i] = node;
        }

        return root;
    }
}

No decrease-key: a cheaper route just pushes the node again, and the stale copy gets skipped when it's popped. Lazy, and faster for it.

01

Binary min-heap

Keeps the lowest-cost node accessible while maintaining O(log n) insertion and removal.

02

26 neighbours

The search is volumetric rather than limited to 4- or 6-directional grid movement.

03

Euclidean heuristic

Distance to the goal provides the heuristic used to prioritize promising nodes.

// 06 · keeping it off the main thread

A hitch is worse than a slow path.

One agent asking for a path is fine. Twenty agents asking on the same frame is a visible stutter, and players forgive a slightly late path far sooner than they forgive a frame spike. So requests go through the Job System and the agent gets called back whenever the answer is ready.

That means native collections throughout — Burst won't touch managed memory — which is the constraint that made bit-packing the grid convenient rather than merely clever.

JOBS

Asynchronous search

Pathfinding work can execute outside the main thread, reducing frame-time interruptions.

BURST

Compiled hot path

The A* implementation is Burst compiled for performance close to native code.

NATIVE

Native collections

The system uses Unity native containers so the data can be safely processed by jobs.

// 07 · making it usable by humans

Nobody should have to type coordinates.

A clever algorithm behind a wall of raw inspector fields is a clever algorithm nobody uses. The default Unity component view would have made you type the bake volume's bounds in as numbers, which is exactly the kind of thing that makes a designer quietly go back to NavMesh.

So the volume gets Scene-view handles you drag, snapped to voxel multiples so you can't accidentally author a half-voxel. Voxel size, wall distance and the bake button live in one dedicated window, and gizmos draw the walkable voxels so you can see what you baked.

01

Scene handles

Resize and position the bake volume visually, snapped to voxel-size multiples.

02

Dedicated window

Configure the bake and inspect the main pathfinding parameters from one place.

03

Debug visualization

Gizmos expose walkable voxels and generated paths directly in the Scene view.

// 08 · what an agent actually does

From a target to a moving agent.

TARGET
→
SNAP TO VOXEL
→
A* JOB
→
PATH
→
FOLLOW

The example agent snaps its position and target to the nearest walkable voxel, requests a path asynchronously and follows the returned points at a constant speed.

When the target moves, the agent can periodically refresh its path. Orientation follows the current path direction, with additional geometry awareness used for banking near obstacles.

// 09 · bake once, not every reload

Bake once. Not every reload.

Sampling a whole volume against physics is slow, and Unity reloads domains constantly. Doing that work again on every script recompile would have been an excellent way to make myself hate this tool, so the grid goes into a ScriptableObject asset.

The voxel size is saved next to the packed data, because packed bits are meaningless without knowing what scale they were packed at — and a silently mismatched voxel size produces paths that are wrong in ways that look almost right.

// 10 · what works today

A reusable 3D navigation prototype.

✓3D voxel gridWorking
✓A* pathfindingWorking
✓Jobs + BurstWorking
✓Bit-packed storageWorking
✓Custom editor toolingWorking
✓Agent exampleWorking
// 11 · where I want to take it

From a static navigation grid to a living navigation system.

The current system is designed around a baked navigation volume. The next step would be to make the navigation data more modular, reusable and responsive to changes in the environment.

01

Multiple grids + bridges

Split large environments into independent voxel grids and connect them through explicit bridges. This would allow different navigation volumes to be updated or streamed independently while still behaving as one navigation network.

02

Exportable pathfinding splines

Convert generated paths into spline data that can be exported and reused by other systems. This could connect the pathfinding tool with animation, camera systems, procedural motion or designer-authored movement.

03

Live obstacle reaction

Detect changes caused by moving objects and update the affected navigation data without requiring a complete world rebake. This would make the system suitable for environments that change during gameplay.

NOW Static voxel navigation
NEXT Modular connected grids
THEN Reusable path data
GOAL Dynamic navigation
// 12 · what this taught me

A* took an afternoon. The rest took the project.

I went in thinking the search was the hard bit. It isn't — it's in every textbook. The hard bit is deciding what the world even is as far as the search is concerned, because that decision sets your memory ceiling, your cache behaviour and your bake time before you've written a line of A*.

Which is the lesson I keep relearning: pick the data structure first, and the algorithm mostly writes itself. Pick the algorithm first and you'll be fighting your own layout for weeks.

// 13 · the long game

Next: make it handle a world that moves.

Right now it's a static bake. Fine for a prototype, useless the moment someone closes a door. That's the next thing.

Back to work ↗