29. NPC Movement Optimisation
Pathfinding entities don't need collision detection. They follow pre-validated paths that already avoid obstacles.
This seemingly minor optimisation yields significant performance benefits and solves difficult terrain navigation.
The Fractal Code Cycle Applied to Pathfinding Optimisation
Recall our fundamental pattern:
Input → Processing → Output
For pathfinding entity physics:
Input: Entity noclip flag status, waypoint positions, velocity
Processing: Skip collision detection for noclip entities, apply velocity directly
Output: Updated entity positions without collision overhead
The Problem
Pathfinding entities were running through the full collision system despite following paths that already avoid obstacles.
Observed Issues:
Entities getting stuck on terrain elevation changes
Wasted CPU cycles checking collisions that would never occur
Frame rate drops when multiple pathfinding entities were active
The Solution: Noclip Flag
Add a flag to bypass collision detection for entities following pre-validated paths:
Entity_Flag :: enum {
Sprite,
Waypoints,
Noclip,
}
Physics System Changes
The physics update now checks the noclip flag:
physics_update :: proc(ws: ^Scene_State) {
for &entity in ws.entities {
if .Noclip in entity.flags {
entity.position += entity.velocity
} else {
entity.position = physics_calculate_next_position(
ws,
entity.position,
entity.velocity,
entity.collider_radius,
)
}
}
}
Noclip entities skip the entire collision detection and response pipeline.
Performance Impact
Measured on 100 entities over multiple frames:
Before (with collision):
870 microseconds per physics update
When combined with debug rendering, exceeded 16.67ms frame budget
Frame rate dropped by ~10 FPS
After (noclip enabled):
2.475 microseconds per physics update
351x performance improvement
Frame rate stable at 60 FPS
Why This Works
Navgrid paths are already obstacle-aware. The grid generation samples collision geometry, so valid paths never intersect with static obstacles.
What noclip entities skip:
Sphere vs OBB3 collision detection
Coordinate space transformations
Iterative collision response calculations
Ground sampling (handled by waypoint elevation)
What they retain:
Path following logic
Waypoint progression
Movement velocity
Waypoint Optimisation
The path generation was also optimised to reduce redundant waypoints:
waypoints_from_path :: proc(ng: Navgrid, path: []int, allocator := context.allocator) -> []Vec3 {
if len(path) == 0 do return nil
points := make([dynamic]Vec3, allocator)
i := 0
for i < len(path) {
curr_pos := ng.graph.nodes[path[i]].pos
append(&points, curr_pos)
if i + 1 >= len(path) do break
next_pos := ng.graph.nodes[path[i + 1]].pos
dx := next_pos.x != curr_pos.x
dy := next_pos.y != curr_pos.y
i += 1
for i < len(path) - 1 {
curr_pos := ng.graph.nodes[path[i]].pos
next_pos := ng.graph.nodes[path[i + 1]].pos
curr_dx := curr_pos.x != next_pos.x
curr_dy := curr_pos.y != next_pos.y
if curr_dx != dx || curr_dy != dy do break
i += 1
}
}
return points[:]
}
This greedy algorithm eliminates waypoints along straight paths, keeping only direction changes.
Waypoint Following Simplification
With noclip enabled, waypoint following becomes straightforward:
if .Waypoints in entity.flags && len(entity.waypoints) > 0 {
dist_check := entity.movement_speed * dt
dest := entity.waypoints[entity.waypoint_index]
dest.y += entity.collider_radius
dir := rl.Vector3Normalize(dest - entity.position)
entity.velocity = dir * entity.movement_speed * dt
dist := rl.Vector3Length(dest - entity.position)
if dist < dist_check {
entity.waypoint_index = entity.waypoint_index + 1
if entity.waypoint_index == len(entity.waypoints) {
entity.waypoint_index = 0
}
}
}
No ground sampling, no XZ plane restrictions. Direct 3D movement to waypoints.
The Performance Threshold Principle
Small overheads compound. Individual systems may seem cheap, but when frame time approaches 16.67ms, any overhead can cause drops below 60 FPS.
The 870 microseconds wasn't "expensive" by itself, but combined with debug rendering overhead, it pushed total frame time over budget.
Optimising it to 2.475 microseconds created enough headroom to maintain stable frame rate.
Design Implications
When to use noclip:
Entities following navgrid paths
Movement guaranteed not to intersect obstacles
NPC patrol routes
Quest-directed entity movement
When to use collision:
Player-controlled entities
Dynamic obstacle interaction required
Physics-based gameplay mechanics
Entities that can be pushed or blocked
The Big Idea
Domain-specific optimisations outperform general solutions. Pathfinding entities don't need full physics simulation because their movement is already constrained by the navgrid.
The 351x performance gain demonstrates how architectural decisions (separating collision from movement) enable targeted optimisations that would be impossible in tightly coupled systems.