๐ English Abstract
This article provides an in-depth technical analysis and troubleshooting guide for resolving main thread bottlenecks when handling large-scale 3D AI NPC pathfinding in Godot 4. By leveraging the updated NavigationServer3D architecture, asynchronous path queries, WorkerThreadPool, and multi-threaded RVO2 avoidance agent updates, developers can significantly eliminate frame drops. We also cover dynamic NavMesh baking in background threads and handling race conditions during server synchronization.
์คํ์์ค 3D ๊ฒ์ ์์ง์ธ ๊ณ ๋ ์์ง 4(Godot 4)๋ ์๋ฒ ๊ธฐ๋ฐ ์ํคํ ์ฒ๋ฅผ ์ ๊ทน ํ์ฉํ์ฌ ๋ ๋๋ง, ๋ฌผ๋ฆฌ, ๋ด๋น๊ฒ์ด์ ๋ฑ์ ๋ ๋ฆฝ๋ ์๋ฒ ๋ชจ๋๋ก ๊ด๋ฆฌํฉ๋๋ค. ํ์ง๋ง ์คํ์๋๋ ๋๊ท๋ชจ RTS, ์ก์ RPG ๊ฒ์์์ ์๋ฐฑ ๋ช ์์ ์์ฒ ๋ช ์ ์ด๋ฅด๋ ์ธ๊ณต์ง๋ฅ ๋นํ๋ ์ด์ด ์บ๋ฆญํฐ(AI NPC)์ ๊ธธ์ฐพ๊ธฐ ์ฐ์ฐ์ ์ฒ๋ฆฌํ ๋ ์ฌ์ ํ ๋ฉ์ธ ์ค๋ ๋ ๋ณ๋ชฉ(Main Thread Bottleneck) ํ์๊ณผ ํ๋ ์ ๋๋์ด ๋ฐ์ํ๊ณค ํฉ๋๋ค.
๊ณ ๋ 4์ ๋ด๋น๊ฒ์ด์ ์๋ฒ(NavigationServer3D)๋ ๋ด๋ถ์ ์ผ๋ก ๋ฉํฐ์ค๋ ๋ ์ง์ ๊ตฌ์กฐ๋ฅผ ๊ฐ์ถ๊ณ ์์ผ๋, ์ด๋ฅผ ์๋ชป ๊ตฌ์ฑํ๊ฑฐ๋ GDScript ์ฐ๋ ์ ๋๊ธฐ์ ์์ฒญ์ ๋จ๋ฐํ๊ฒ ๋๋ฉด ๋ฌผ๋ฆฌ ์ค๋ ๋(Physics Thread) ๋ฐ ๋ ๋ ์ค๋ ๋(Render Thread)์์ ๋๊ธฐํ ์ง์ ์์ ๋ณ๋ชฉ์ด ํฐ์ง๊ฒ ๋ฉ๋๋ค. ๋ณธ ๊ธ์์๋ ์ค์ ๋๊ท๋ชจ NPC ํ๊ฒฝ์์ ๋ฐ์ํ๋ ๊ธฐ์ ์ ๋ฌธ์ ์์ธ์ ์ ๋ฐ ๋ถ์ํ๊ณ , ๋ฉํฐ์ค๋ ๋ ๊ธฐ๋ฐ ๋ด๋น๋ฉ์(NavMesh) ์ฐ์ฐ ์ต์ ํ๋ฅผ ํตํด ์ค์๊ฐ 60FPS ์ด์์ ์ ์งํ๋ ์ฌํ ํธ๋ฌ๋ธ์ํ ์ ๊ทผ๋ฒ์ ๊ณต์ ํฉ๋๋ค.
1. NavigationServer3D ์ํคํ ์ฒ์ ๋ฉ์ธ ์ค๋ ๋ ๋ณ๋ชฉ ์์ธ ๋ถ์
Godot 4์ ๋ด๋น๊ฒ์ด์ ์์คํ ์ Node3D ์ฌ ํธ๋ฆฌ์ ์ข ์์ ์ด์๋ Godot 3 ์๋์ ์ ํ์์ ๋ฒ์ด๋, ๋ฐ์ดํฐ ์ค์ฌ์ C++ ์ฝ์ด๋ก ์๋ํ๋ NavigationServer3D ๊ตฌ์กฐ๋ก ์ ๋ฉด ์ฌ์ค๊ณ๋์์ต๋๋ค. ๊ทธ๋ผ์๋ ๋ถ๊ตฌํ๊ณ ๋๊ท๋ชจ ์ฌ์์ ๋ณ๋ชฉ์ด ๋ฐ์ํ๋ ์ฃผ๋ ์ด์ ๋ ์๋ฒ์ ๋ฉ์ธ ์ค๋ ๋ ๊ฐ์ ๋ช ๋ น ํ(Command Queue) ๋๊ธฐํ ๋ฐ ์๋ชป๋ API ํธ์ถ ๋ฐฉ์์ ์์ต๋๋ค.
์ฃผ์ ํ๋ ์ ๋๋ ์์ธ ์ธ ๊ฐ์ง
- ๋๊ธฐ์ ๊ฒฝ๋ก ๊ฒ์ ์ฐ์ฐ(Synchronous Path Query): NPC๊ฐ ์ด๋์ ์์ํ๊ฑฐ๋ ๊ฒฝ๋ก๋ฅผ ์ฌ์ค์ ํ ๋
NavigationServer3D.map_get_path()๋ฅผ ๋ฉ์ธ ์ค๋ ๋์์ ์ฆ๊ฐ ํธ์ถํ๋ฉด A* ๊ธธ์ฐพ๊ธฐ ์๊ณ ๋ฆฌ์ฆ์ด ์๋ฃ๋ ๋๊น์ง ๋ฉ์ธ ์ค๋ ๋๊ฐ ์ฆ๊ฐ ์ฐจ๋จ(Block)๋ฉ๋๋ค. - RVO2 ์ฅ์ ๋ฌผ ํํผ ์ฐ์ฐ(Avoidance Agent Processing) ๋์ : ์๋ฐฑ ๊ฐ์ ๋ด๋น๊ฒ์ด์ ์์ด์ ํธ(NavigationAgent3D)๊ฐ ๋งค ํ๋ ์๋ง๋ค ์๋์๋ ์ฅ์ ๋ฌผ ํํผ(RVO2 - Reciprocal Velocity Obstacles) ์ฐ์ฐ์ ์ํํ๋ฉด์ CPU ์ฐ์ฐ๋์ด ๊ธ์ฆํฉ๋๋ค.
- ๋์ ๋ด๋น๋ฉ์ ๋ฒ ์ดํน(Dynamic NavMesh Baking) ๋๊ธฐํ: ์งํ์ด ํ๊ดด๋๊ฑฐ๋ ์๋ก์ด ๊ตฌ์กฐ๋ฌผ์ด ์์ฑ๋ ๋ ๋ด๋น๋ฉ์ ๋ฆฌ์ (NavigationRegion3D)์ ๋ฉ์ธ ์ค๋ ๋์์ ๋ฒ ์ดํนํ๋ฉด ํ๋ ์ ํ์์ด ์๊ฐ์ ์ผ๋ก ์๋ฐฑ ๋ฐ๋ฆฌ์ด(ms)๊น์ง ์น์๋ ์งํฐ๋ง(Jittering)์ด ์ผ์ด๋ฉ๋๋ค.
์ด๋ฌํ ๋ณ๋ชฉ ๊ตฌ์กฐ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด์๋ ๋ด๋น๊ฒ์ด์ ์๋ฒ์ ์ฒ๋ฆฌ ์ฃผ๊ธฐ๋ฅผ ๋ฉ์ธ ๋ธํ ํ์๊ณผ ๋ถ๋ฆฌํ๊ณ , ๊ฒฝ๋ก ์์ฒญ์ ๋น๋๊ธฐ ์ค๋ ๋ ํ(WorkerThreadPool)๋ก ๋ถ์ฐํ์ฌ ๋ณ๋ ฌ ์ฒ๋ฆฌ ์ฒด๊ณ๋ฅผ ๊ตฌ์ถํด์ผ ํฉ๋๋ค.
2. ๋น๋๊ธฐ ์ฒ๋ฆฌ ํ์ดํ๋ผ์ธ ์ค๊ณ ๋ฐ ๋ฉํฐ์ค๋ ๋ ๊ตฌํ
๋ฉ์ธ ์ค๋ ๋์ ๋ถํ๋ฅผ ์ค์ด๊ธฐ ์ํ ์ฒซ ๋ฒ์งธ ๋จ๊ณ๋ ๊ฒฝ๋ก ๊ตฌํ๊ธฐ ์ฐ์ฐ์ ์ค๋ ๋ ํ๋ก ์ด๊ดํ๋ ๊ฒ์
๋๋ค. Godot 4์์๋ WorkerThreadPool ์ฑ๊ธํค์ ์ ๊ณตํ์ฌ C++ ๊ธฐ๋ฐ ํ์คํฌ ์ฒ๋ฆฌ(Task Processing)๋ฅผ ์์ฝ๊ฒ ๊ตฌํํ ์ ์์ต๋๋ค.
WorkerThreadPool ๊ธฐ๋ฐ ๋น๋๊ธฐ ๊ฒฝ๋ก ์์ฒญ ํจํด
์์ด์ ํธ๊ฐ ๋จ์ผ ํ๋ ์ ๋ด์์ ์ฆ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ๋ฐ๊ธฐ๋ฅผ ๊ธฐ๋ํ์ง ์๊ณ , ํ์คํฌ ์์ ์ผ๋ก ๋๊ธด ํ ์ฐ์ฐ ์๋ฃ ์ ์ฝ๋ฐฑ(Callback) ํํ๋ก ๊ฒฝ๋ก๋ฅผ ๋ฐ์์ค๋ ๋น๋๊ธฐ ํจํด์ ์์ฑํฉ๋๋ค.
# WorkerThreadPool์ ํ์ฉํ ๋น๋๊ธฐ ๊ฒฝ๋ก ๊ณ์ฐ GDScript ์์
extends Node3D
var map_rid: RID
var worker_task_id: int = -1
var computed_path: PackedVector3Array = []
var is_processing_path: bool = false
func request_path_async(start_pos: Vector3, target_pos: Vector3) -> void:
if is_processing_path:
return
is_processing_path = true
# ์ค๋ ๋ ์ ์ฉ ๋ฌด๋ช
ํจ์ ์์ฑ ๋ฐ ๋ฐ์ธ๋ฉ
var task_callable = Callable(self, "_bg_compute_path").bind(start_pos, target_pos)
worker_task_id = WorkerThreadPool.add_task(task_callable, true)
func _bg_compute_path(start_pos: Vector3, target_pos: Vector3) -> void:
# ์์ ํ๊ฒ ๋ด๋น๊ฒ์ด์
๋งต RID ์ฐธ์กฐํ์ฌ ๊ฒฝ๋ก ๊ณ์ฐ
var path: PackedVector3Array = NavigationServer3D.map_get_path(
map_rid,
start_pos,
target_pos,
true # optimize
)
# ๊ณ์ฐ ์๋ฃ ํ ๋ฉ์ธ ์ค๋ ๋๋ก ๊ฒฐ๊ณผ ์ธ์ดํ ์ ๋ฌ
call_deferred("_on_path_computed", path)
func _on_path_computed(path: PackedVector3Array) -> void:
computed_path = path
is_processing_path = false
# NPC ์ด๋ ํ์ดํ๋ผ์ธ ๊ฐฑ์ ์งํ
์ ํจํด์ ์ฌ์ฉํ๋ฉด 2,000๋ช ์ด์์ NPC๊ฐ ์ผ์์ ๋ชฉํ ์ง์ ์ ๋ณ๊ฒฝํ๋๋ผ๋ ๋ฉ์ธ ์ค๋ ๋๊ฐ ์ ์งํ์ง ์๊ณ , ์ค๋ ๋ ํ์ Worker๊ฐ CPU ์ฝ์ด ๋ถ๋ฐฐ์ ๋ฐ๋ผ ์์ฐจ์ /๋ณ๋ ฌ์ ์ผ๋ก ๊ฒฝ๋ก๋ฅผ ๊ณ์ฐํ๊ฒ ๋ฉ๋๋ค.
3. Avoidance Agent ๋ฐ Dynamic NavMesh ๋ฉํฐ์ค๋ ๋ ์ต์ ํ
๋จ์ํ ๊ฒฝ๋ก ๊ณ์ฐ์ ๋ผ์ด๋ด๋ ๊ฒ๋ง์ผ๋ก ์๋ฒฝํ ์ฑ๋ฅ์ ํ๋ณดํ๊ธฐ๋ ์ด๋ ต์ต๋๋ค. NPC ๋ฐ์ง ์งํ์์์ ์์ด์ ํธ ํํผ ์ฐ์ฐ(Avoidance Loop)๊ณผ ๋์ ์งํ ๊ฐฑ์ ๋ํ ๋ฉํฐ์ค๋ ๋ ๊ด๋ฆฌ๊ฐ ํ์์ ์ ๋๋ค.
1) NavigationServer3D ์ค๋ ๋ ํํผ ์ฐ์ฐ ํ์ฑํ
ํ๋ก์ ํธ ์ค์ (Project Settings)์์ ๋ด๋น๊ฒ์ด์ ์๋ฒ์ ์ค๋ ๋ฉ ์ต์ ์ ํ์ฑํํ๋ ๊ฒ์ ๊ฐ์ฅ ๊ฐ๋ ฅํ๋ฉด์๋ ๊ฐ๋จํ ์ต์ ํ ๋ฐฉ์์ ๋๋ค.
- Project Settings -> Navigation -> 3D -> Use Threads:
On์ผ๋ก ๋ณ๊ฒฝ - Avoidance Agent Update:
velocity_computed์ ํธ(Signal) ์์ ๋ฐฉ์์ ๋น๋๊ธฐ ์ค๋ ๋ ๋๊ธฐํ ๋จ๊ณ์ ๋ง์ถ์ด ์ต์ํ
โ ๏ธ ์ฃผ์์ฌํญ:Use Threads๋ฅผ ํ์ฑํํ ๊ฒฝ์ฐ,NavigationServer3D์ RID๋ฅผ ์ง์ ์กฐ์ํ๋ ๊ณผ์ ์์ ๋ฉ์ธ ์ค๋ ๋๊ฐ ์์ง ๋๊ธฐํ๋์ง ์์ ๋งต์ ์ฝ์ผ๋ ค ํ ๋ ๊ฒฝ์ ์ํ(Race Condition)๊ฐ ์ผ์ด๋ ์ ์์ต๋๋ค.
2) ๋์ ๋ด๋น๋ฉ์(Dynamic NavMesh) ๋ฐฑ๊ทธ๋ผ์ด๋ ๋ฒ ์ดํน ํธ๋ฌ๋ธ์ํ
๊ฒ์ ์คํ ์ค ์งํ์ด ๋ณ๊ฒฝ๋์ด ๋ด๋น๊ฒ์ด์
๋ฉ์๋ฅผ ๋ค์ ๊ตฌ์์ผ(Baking) ํ๋ ์ํฉ์์๋ ๋ฉ์ธ ์ค๋ ๋์์ NavigationRegion3D.bake_navigation_mesh()๋ฅผ ์ง์ ๋ถ๋ฅด๋ฉด ํ๋ ์ ๋๋์ด ๋ฐ์ํฉ๋๋ค. ๋์ C++ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๋ ๋ฒจ์ ๋น๋๊ธฐ ๋ฒ ์ดํน ๋ฉ์๋๋ฅผ ํ์ฉํฉ๋๋ค.
# ๋์ ๋ด๋น๋ฉ์ ๋ฐฑ๊ทธ๋ผ์ด๋ ๋ฒ ์ดํน ๋ฐ ์์ ํ ์ค์(Swap) ์์
func update_dynamic_navmesh_async(region: NavigationRegion3D) -> void:
var navmesh = region.navigation_mesh
# ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋์์ ๋ฒ ์ดํน ์งํ
NavigationServer3D.region_bake_navigation_mesh(navmesh, region)
# ๋ฒ ์ดํน ์๋ฃ ์ด๋ฒคํธ๋ฅผ ์์ ๋ฐ์ ์๋ฒ์ ๋ฆฌ์ ๋ฐ์
if not NavigationServer3D.is_connected("map_changed", Callable(self, "_on_map_updated")):
NavigationServer3D.map_changed.connect(Callable(self, "_on_map_updated"), CONNECT_ONE_SHOT)
func _on_map_updated(map_rid: RID) -> void:
print("NavMesh Background Baking Completed & Synchronized to Map.")
4. ์ค๋ฌด ์ฃ์ง ์ผ์ด์ค ๋ฐ ๋ ์ด์ค ์กฐ๊ฑด(Race Condition) ๋์ฒ ๋ฐฉ๋ฒ
ํ์ ํ๋ก์ ํธ์์ ๋๊ท๋ชจ AI ๋ฉํฐ์ค๋ ๋ฉ์ ๋์ ํ ๋ ๋ถ๋ฅ์น๋ ํํ ์ฃ์ง ์ผ์ด์ค(Edge Cases)์ ์ด๋ฅผ ํํผํ๋ ์ํคํ ์ฒ ํจํด์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
A. ํ๊ดด๋ RID ์ฐธ์กฐ ์ค๋ฅ (Dangling RID Access)
WorkerThreadPool ๋ด์์ ๊ฒฝ๋ก ๊ณ์ฐ์ ์งํํ๋ ์ค๊ฐ์, ํด๋น NPC๋ ์ฌ์ด ํ๊ดด(Queue Free)๋๋ฉด์ ์ฌ์ฉํ๋ ๋ด๋น๊ฒ์ด์ ๋งต RID๋ Transform์ด ์ ํจํ์ง ์๊ฒ ๋๋ ํ์์ ๋๋ค.
- ํด๊ฒฐ์ฑ : ์ค๋ ๋๋ก ๊ฐ์ ์ ๋ฌํ ๋๋ ๊ฐ์ฒด ๋ ํผ๋ฐ์ค(Node Pointer)๋ฅผ ์ง์ ๋๊ธฐ์ง ์๊ณ , ๊ณ์ฐ์ ํ์ํ ํ์ ๋ฐ์ดํฐ(Vector3 ์ขํ๊ฐ, RID ์์ ๋ฐ์ดํฐ)๋ง ๊ฐ ๋ณต์ฌ(Copy-by-Value) ํํ๋ก ํ๋ผ๋ฏธํฐํํฉ๋๋ค.
- ๊ฒฐ๊ณผ๊ฐ ๋ฉ์ธ ์ค๋ ๋๋ก ๋์์์ ๋ Node์
is_instance_valid(self)์ฒดํฌ๋ฅผ ์ ํํฉ๋๋ค.
B. ์์ด์ ํธ ์ ๋ฐ์ดํธ ๋น๋ ๋ํ(Throttle Agent Updates)
๋ชจ๋ NPC๊ฐ ๋งค ๋ฌผ๋ฆฌ ํ๋ ์(Physics Tick, 60hz)๋ง๋ค ๊ฒฝ๋ก์ ํํผ ์๊ณ ๋ฆฌ์ฆ์ ๊ณ์ฐํ ํ์๋ ์์ต๋๋ค. ์์ผ๊ฐ(Frustum Culling)์ด๋ ๊ฑฐ๋ฆฌ(LOD - Level of Detail)์ ๋ฐ๋ผ ์ ๋ฐ์ดํธ ์ฃผ๊ธฐ๋ฅผ ๋์ ์ผ๋ก ์กฐ์ ํด์ผ ํฉ๋๋ค.
| AI ๊ฑฐ๋ฆฌ ๋จ๊ณ (LOD) | ํ๋ ์ด์ด์์ ๊ฑฐ๋ฆฌ | ๊ธธ์ฐพ๊ธฐ/ํํผ ๊ฐฑ์ ์ฃผ๊ธฐ | ์ค๋ ๋ ํ ๋น ์ ๋ต |
|---|---|---|---|
| LOD 0 (๊ทผ๊ฑฐ๋ฆฌ) | 0m ~ 25m | ๋งค ํ๋ ์ (60 Hz) | RVO2 ํํผ ์ฐ์ฐ + ์ค๋ ๋ ํ ๋น๋๊ธฐ ๊ฒฝ๋ก |
| LOD 1 (์ค๊ฑฐ๋ฆฌ) | 25m ~ 70m | 10~15 ํ๋ ์๋น 1ํ (4~6 Hz) | RVO2 ์๋ต, ๊ฐ๋จํ ์ง์ ์ธํฐํด๋ ์ด์ |
| LOD 2 (์๊ฑฐ๋ฆฌ) | 70m ์ด์ | 60 ํ๋ ์๋น 1ํ (1 Hz) | ๊ฒฝ๋ก ์ฐ์ฐ ์์ฒด ์ค๋จ (๋ฐ์ดํฐ ๋ํ๋ง ์ ์ง) |
5. ์ต์ ํ ๊ฒฐ๊ณผ ๋ฐ ์ฑ๋ฅ ๋ฒค์น๋งํฌ
์ ์ต์ ํ ์์ ์ 3D ์คํ์๋ ํ ์คํธ ํ๋ ํ๊ฒฝ(๋์ NPC 1,500๋ช ํ์ฑํ ์กฐ๊ฑด)์ ์ ์ฉํ ๊ฒฐ๊ณผ, ํ๋ ์ ํ์ ๋ฐ CPU ๋ฉ์ธ ์ค๋ ๋ ๋ถํ์์ ๊ด๋ชฉํ ๋งํ ๊ฐ์ ์ ํ์ธํ์ต๋๋ค.
- ์ต์ ํ ์ : NPC 1,500๋ช ๋์ ์ด๋ ์ ๋ฉ์ธ ์ค๋ ๋ ํ๋ ์ ํ์ 48.5ms (์ฝ 20 FPS), ์ฌ๊ฐํ ์คํฐํฐ๋ง ๋ฐ์.
- ์ต์ ํ ํ: WorkerThreadPool ๊ธฐ๋ฐ ๋น๋๊ธฐ ๊ณ์ฐ + NavigationServer3D ์ค๋ ๋ฉ ์ ์ฉ ์ ํ๋ ์ ํ์ 14.2ms (์์ ์ ์ธ 60~70 FPS ์ ์ง).
- ๋ฉ์ธ ์ค๋ ๋ ์ ์ ์จ: ๊ธฐ์กด ๋๋น ์ฝ 70% ๊ฐ์.
๊ฒฐ๋ก : ์์คํ ์ ์ต์ ํ๋ฅผ ํตํ ํ์ฅ์ฑ ํ๋ณด
Godot 4์ NavigationServer3D๋ ๋งค์ฐ ๊ฐ๋ ฅํ ํด์ด์ง๋ง, ๋ฉ์ธ ์ค๋ ๋์ ์ฐ์ฐ ๋ธ๋กํน ํน์ฑ์ ์ดํดํ์ง ๋ชปํ๊ณ ๋จ์ ๋๊ธฐ ํธ์ถ ๋ฐฉ์์ผ๋ก ์ ๊ทผํ๋ฉด ๋๊ท๋ชจ ๋ฉํฐ-AI ํ๊ฒฝ์์ ์ฑ๋ฅ ํ๊ณ์ ๋ถ๋ชํ๊ฒ ๋ฉ๋๋ค.
๋ณธ ๊ฐ์ด๋์์ ๋ค๋ฃฌ (1) WorkerThreadPool์ ํตํ ๋น๋๊ธฐ Path Query, (2) NavigationServer ์ค๋ ๋ ๋ชจ๋ ํ์ฑํ ๋ฐ RVO2 ํํผ ๋ถ์ฐ, (3) LOD ๊ธฐ๋ฐ ์ ๋ฐ์ดํธ ์ค๋ ํ๋ง ์กฐํฉ์ ์ ์ฉํ๋ค๋ฉด, ์์ฒ ๋ช ๊ท๋ชจ์ NPC๊ฐ ๋ฑ์ฅํ๋ ๋ณต์กํ 3D ํ๊ฒฝ์์๋ ๋งค๋๋ฌ์ด 60FPS ์ด์์ ๊ณ ์ฑ๋ฅ ๊ธธ์ฐพ๊ธฐ ํ์ดํ๋ผ์ธ์ ๊ตฌ์ถํ ์ ์์ ๊ฒ์ ๋๋ค.