๐ English Abstract
This article explores methods to resolve severe frame drops caused by pathfinding bottlenecks in Godot 4 2D top-down games featuring large numbers of AI agents. It provides an in-depth analysis of the NavigationServer2D architecture, RVO2 avoidance overhead, and asynchronous multi-threaded path request strategies using WorkerThreadPool. Furthermore, practical troubleshooting tips and code implementations are detailed to handle edge cases like dynamic map updates and thread-safety issues during high-density unit simulation.
Godot 4 ์์ง์ ์๋ฒ ๊ธฐ๋ฐ ์ํคํ ์ฒ(Server-based Architecture)๋ฅผ ์ ๋ฉด์ ๋์ ํ๋ฉด์ Rendering, Physics์ ํจ๊ป Navigation ๋ถ์ผ์์๋ ๋น์ฝ์ ์ธ ์ฑ๋ฅ ํฅ์์ ์ด๋ฃจ์ด๋์ต๋๋ค. ํ์ง๋ง ๋ฑํ์ด์ด ์๋ฐ์ด๋ฒ์ฆ(Vampire Survivors) ์คํ์ผ์ ์ค์(Swarm)๋ฅ ๊ฒ์์ด๋ RTS, 2D ํ๋ค์ด ์ก์ RPG์ฒ๋ผ ์๋ฐฑ ๊ฐ์์ ์์ฒ ๊ฐ์ ์์ด์ ํธ(Agent)๊ฐ ๋์์ ๊ธธ์ฐพ๊ธฐ(Pathfinding) ์ฐ์ฐ์ ์ํํ๋ ํ๊ฒฝ์์๋ ์ฌ์ ํ ์ฌ๊ฐํ ๋ฉ์ธ ์ค๋ ๋ ๋ณ๋ชฉ ํ์(Main Thread Bottleneck)๊ณผ ํ๋ ์ ๋๋กญ(Frame Drop)์ด ๋ฐ์ํฉ๋๋ค.
๋ณธ ํฌ์คํ
์์๋ Godot 4์ NavigationServer2D ์๋ ์๋ฆฌ๋ฅผ ๊น์ด ์๊ฒ ๋ถ์ํ๊ณ , ๋๊ธฐ์(Synchronous) ๋ด๋น๊ฒ์ด์
์ฐ์ฐ์ด ๋ฐ์ํ๋ ๊ตฌ์กฐ์ ์์ธ์ ํ์
ํฉ๋๋ค. ์์ธ๋ฌ ์ด๋ฅผ ๋น๋๊ธฐ ๋ฉํฐ์ค๋ ๋(Asynchronous Multi-threading) ํ์ดํ๋ผ์ธ์ผ๋ก ์ ํํ์ฌ 60 FPS ์ด์์ ์์ ์ ์ธ ํ๋ ์์ ์ ์งํ๋ ์ค์ ํธ๋ฌ๋ธ์ํ
๊ณผ์ ๊ณผ ์ต์ ํ ๊ธฐ๋ฒ์ ๊ณต์ ํฉ๋๋ค.
1. NavigationServer2D์ ์ํคํ ์ฒ ๋ฐ ๋ณ๋ชฉ ์์ธ ๋ถ์
Godot 4์ ๋ด๋น๊ฒ์ด์
์์คํ
์ ๋
ธ๋ ํธ๋ฆฌ(Node Tree)์ ๋ถ๋ฆฌ๋ ์ ์ฉ ์๋ฒ(Server) ๊ตฌ์กฐ๋ก ๋์ํฉ๋๋ค. NavigationRegion2D๋ ๋ด๋น๊ฒ์ด์
๋ฉ์(Navigation Mesh)๋ฅผ ๊ด๋ฆฌํ๊ณ , NavigationAgent2D๋ ๊ฐ๋ณ ๊ฐ์ฒด์ ์ด๋ ์์ฒญ ๋ฐ ํํผ ์ฐ์ฐ์ ๋ด๋นํฉ๋๋ค.
์๋ฐฑ ๊ฐ ์ด์์ ์ AI๊ฐ ํ ํ๋ฉด์ ๋ฑ์ฅํ ๋ ํ๋ ์ ๋๋กญ์ด ๋ฐ์ํ๋ ์ฃผ์ ์์ธ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- ๋๊ธฐ์ ๊ฒฝ๋ก ๊ณ์ฐ ์คํ์ดํฌ(Synchronous Pathfinding Spike): ์๋ฐฑ ๊ฐ์ ์์ด์ ํธ๊ฐ ๋์ผํ ํ๋ ์์
NavigationServer2D.map_get_path()๋ฅผ ํธ์ถํ๋ฉด, CPU์ ๋ฉ์ธ ์ค๋ ๋๊ฐ A* ์๊ณ ๋ฆฌ์ฆ ์ฐ์ฐ์ ํ๊บผ๋ฒ์ ์ฒ๋ฆฌํ๋๋ผ ๋ ๋๋ง ๋ฐ ๋ฌผ๋ฆฌ ํ๋ ์ ํ์(Frame Time)์ด ์๊ฐ์ ์ผ๋ก ํญ์ฆํฉ๋๋ค. - RVO2 ํํผ ์ฐ์ฐ(Reciprocal Velocity Obstacles Overhead): ์์ด์ ํธ ๊ฐ ์ถฉ๋์ ๋ฐฉ์งํ๋ ํํผ ์๊ณ ๋ฆฌ์ฆ์ $O(N^2)$์ ๊ฐ๊น์ด ๊ณ์ฐ ๋ณต์ก๋๋ฅผ ๊ฐ์ง๋๋ค. ๊ฐ ์์ด์ ํธ์ ์ถฉ๋ ๋ฐ๊ฒฝ๊ณผ ์๋ ๋ฒกํฐ๋ฅผ ๋งค ํ๋ ์ ๋น๊ตํ๋ ์ฐ์ฐ์ ๋ฉ์ธ ์ค๋ ๋์ ์์ฒญ๋ ๋ถํ๋ฅผ ์ค๋๋ค.
- ๋ถํ์ํ๊ฒ ๋์ ์ฌ๊ณ์ฐ ์ฃผ๊ธฐ(High Path Recalculation Frequency): ํ๊ฒ(ํ๋ ์ด์ด)์ด ์กฐ๊ธ๋ง ์์ง์ฌ๋ ๋ชจ๋ ์์ด์ ํธ๊ฐ ๋งค ํ๋ ์(๋๋ Physics Frame๋ง๋ค) ๊ฒฝ๋ก๋ฅผ ์ฌ๊ณ์ฐ(Path Refresh)ํ๋๋ก ์ค์ ๋์ด ์๋ ๊ฒฝ์ฐ์ ๋๋ค.
Godot Profiler๋ฅผ ํตํ ํ๋ ์ ํ์ ๋ถ์
์์ง ๋ด๋ถ ํ๋กํ์ผ๋ฌ(Profiler)๋ฅผ ํตํด ํ์ธํด ๋ณด๋ฉด, Physics Frame Time ํญ๋ชฉ์์ navigation_process ์๊ฐ์ด 16.6ms(60FPS ๊ธฐ์ค)๋ฅผ ํจ์ฌ ์ด๊ณผํ์ฌ 30~50ms๊น์ง ์น์๋ ํ์์ ๊ด์ฐฐํ ์ ์์ต๋๋ค. ์ด๋ ๋ฉ์ธ ์ค๋ ๋๊ฐ ๋ด๋น๊ฒ์ด์
๋๊ธฐํ ์ฒ๋ฆฌ๊ฐ ์๋ฃ๋ ๋๊น์ง ๋๊ธฐ(Wait State) ์ํ์ ๋น ์ง๊ธฐ ๋๋ฌธ์
๋๋ค.
2. ๋น๋๊ธฐ ๋ฉํฐ์ค๋ ๋ ๊ธธ์ฐพ๊ธฐ ํ์ดํ๋ผ์ธ ๊ตฌ์ถ
๋ฉ์ธ ์ค๋ ๋์ ๋ถ๋ด์ ๋์ด์ฃผ๊ธฐ ์ํด ๊ฒฝ๋ก ์ฐ์ฐ ์์
์ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋(Background Thread)๋ก ์ด์ ํด์ผ ํฉ๋๋ค. Godot 4์์๋ WorkerThreadPool์ ํ์ฉํ์ฌ ์ด ์์
์ ํจ์จ์ ์ผ๋ก ์ฒ๋ฆฌํ ์ ์์ต๋๋ค.
๊ฒฝ๋ก ๊ณ์ฐ์ ๋ถ์ฐ ์ฒ๋ฆฌ (Time-Slicing & Throttling)
๋ชจ๋ ์์ด์ ํธ๊ฐ ๋์์ ๊ฒฝ๋ก๋ฅผ ๊ตฌํ์ง ์๋๋ก ํ์ ์ฌ๋ผ์ด์ฑ(Time-Slicing) ๊ธฐ๋ฒ์ ์ ์ฉํฉ๋๋ค. ์์ด์ ํธ ๊ทธ๋ฃน์ ๋๋ ๋ค, ๊ฐ ๊ทธ๋ฃน์ด ์๋ก ๋ค๋ฅธ ํ๋ ์์ ๋น๋๊ธฐ ๊ฒฝ๋ก ์์ฒญ์ ๋ณด๋ผ ์ ์๋๋ก ๋งค๋์ ํด๋์ค(NavigationManager)๋ฅผ ์ค๊ณํฉ๋๋ค.
์๋๋ WorkerThreadPool์ ์ด์ฉํ์ฌ ๋น๋๊ธฐ์ ์ผ๋ก ๊ฒฝ๋ก๋ฅผ ์์ฑํ๊ณ ์ฝ๋ฐฑ์ ํตํด ์ ๋ฌ๋ฐ๋ ํจํด์ GDScript ๊ตฌํ ์์์
๋๋ค.
# AsyncNavigationManager.gd
extends Node
# ๋งค ํ๋ ์ ์ฒ๋ฆฌํ ์ต๋ ๋น๋๊ธฐ ๊ธธ์ฐพ๊ธฐ ์์
์
const MAX_ASYNC_TASKS_PER_FRAME = 20
var task_queue: Array = []
func request_path_async(map_rid: RID, start_pos: Vector2, end_pos: Vector2, callback: Callable) -> void:
task_queue.append({
"map": map_rid,
"start": start_pos,
"end": end_pos,
"callback": callback
})
func _process(_delta: float) -> void:
if task_queue.is_empty():
return
var count = min(task_queue.size(), MAX_ASYNC_TASKS_PER_FRAME)
for i in range(count):
var task_data = task_queue.pop_front()
WorkerThreadPool.add_task(_calculate_path_worker.bind(task_data), true)
func _calculate_path_worker(task_data: Dictionary) -> void:
# NavigationServer2D ์ค๋ ๋ ์์ ํจ์ ํธ์ถ
var path = NavigationServer2D.map_get_path(
task_data["map"],
task_data["start"],
task_data["end"],
true
)
# ๊ฒฐ๊ณผ๋ฅผ ๋ฉ์ธ ์ค๋ ๋๋ก ์์ ํ๊ฒ ์ ๋ฌ
task_data["callback"].call_deferred(path)
3. ์ค๋ฌด ํธ๋ฌ๋ธ์ํ : ๋ฉํฐ์ค๋ ๋ฉ ๋์ ์ ๋ฐ์ ๋ฌธ์ ๋ฐ ์ฃ์ง ์ผ์ด์ค
๋น๋๊ธฐ ๋ฉํฐ์ค๋ ๋ ๋ฐฉ์์ ๋์ ํ๋ฉด ํ๋ ์ ๋๋กญ์ ํฌ๊ฒ ๊ฐ์ ๋์ง๋ง, ๋ฉํฐ์ค๋ ๋ ํน์ ์ ๊ฒฝ์ ์ํ(Race Condition)์ ๋ฐ์ดํฐ ๋๊ธฐํ ์ค๋ฅ๋ผ๋ ์๋ก์ด ๋ฌธ์ ์ ์ง๋ฉดํ๊ฒ ๋ฉ๋๋ค. ์ค๋ฌด์์ ๊ฒช์ ๋ํ์ ์ธ ์ฃ์ง ์ผ์ด์ค์ ํด๊ฒฐ์ฑ ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
1) ๋์ ํ์ผ๋งต ํ๊ดด/์์ฑ ์ ๋ด๋น๊ฒ์ด์ ๋งต ๊ฐฑ์ ๋๊ธฐํ ๋ฌธ์
2D ํ๋ค์ด ๊ฒ์์์ ์งํ์ด ํ๊ดด๋๊ฑฐ๋ ๊ฑด๋ฌผ์ด ๊ฑด์ค๋์ด TileMap์ ๋ด๋น๊ฒ์ด์
์์ญ์ด ์ฌ๋ฒ ์ดํน(Re-baking)๋ ๋, ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋๊ฐ ๊ตฌ๋ฒ์ ๋ด๋น๊ฒ์ด์
RID ๋งต ๋ฐ์ดํฐ์ ์ ๊ทผํ๋ฉด ์ฌ๋ฐ๋ฅด์ง ์์ ๊ฒฝ๋ก๊ฐ ๋ฐํ๋๊ฑฐ๋ ์์ง ๋ด๋ถ ์ธ๊ทธ๋จผํธ ์๋ฌ(Segmentation Fault)๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.
ํด๊ฒฐ์ฑ
: ์งํ ๋ฐ์ดํฐ๊ฐ ๋ณ๊ฒฝ๋๋ ์์ ์๋ NavigationServer2D.map_changed ์ ํธ๋ฅผ ์์ ํ๊ณ , ํ์ฌ ๋๊ธฐ ์ค์ธ ๋ชจ๋ ๋น๋๊ธฐ ์์
ํ(Task Queue)๋ฅผ ์ฆ์ ๋น์ด(Flush) ํ ๋ด๋น๊ฒ์ด์
๋งต ๋๊ธฐํ๊ฐ ๋๋ ํ๋ ์ ๋ค์๋ถํฐ ๋ค์ ๊ฒฝ๋ก ์์ฒญ์ ๋ฐ๋๋ก ์ ์ด ๋ก์ง์ ์์ฑํด์ผ ํฉ๋๋ค.
2) RVO2 Avoidance ์ฐ์ฐ ์ธํ ์ต์ ํ
NavigationAgent2D์ enable_avoidance = true ์ค์ ์ ๋งค์ฐ ์ ์ฉํ์ง๋ง, 500๊ฐ ์ด์์ ๊ฐ์ฒด์์๋ ํํผ ์ฐ์ฐ ์์ฑ๊ฐ ๋ณ๋ชฉ์ด ๋ฉ๋๋ค. ํํผ ์ฐ์ฐ์ ์๋ฒฝํ ์ค๋ ๋ ๋ถ๋ฆฌ๊ฐ ์ด๋ ต๊ธฐ ๋๋ฌธ์ ํ๋ผ๋ฏธํฐ ํ๋์ด ํ์์ ์
๋๋ค.
- Avoidance Layers ๊ตฌ๋ถ: ์ฃผ์ ์๋ฆฌํธ ๋ชฌ์คํฐ๋ ํ๋ ์ด์ด์๊ฒ๋ง ๋์ ์ฐ์ ์์ ํํผ ๋ ์ด์ด๋ฅผ ๋ถ์ฌํ๊ณ , ์ก๋ชน(Minions) ๊ทธ๋ฃน์ ์๋ก ๊ฐ์ Avoidance๋ฅผ ๋นํ์ฑํ(
avoidance_enabled = false)ํ๊ฑฐ๋ ๋จ์ ๊ฑฐ๋ฆฌ ๊ธฐ๋ฐ ๋ฐ์ณ๋ด๊ธฐ(Soft Collision / Separation Force) 2D ๋ฌผ๋ฆฌ ์ฐ์ฐ์ผ๋ก ๋์ฒดํฉ๋๋ค. - Max Neighbors ์ ํ:
neighbor_distance๋ฅผ ์ค์ด๊ณmax_neighbors์์น๋ฅผ ๊ธฐ๋ณธ๊ฐ(10 ์ด์)์์ 3~4 ์ ๋๋ก ๊ณผ๊ฐํ๊ฒ ํํฅ ์กฐ์ ํฉ๋๋ค.
3) ๊ฑฐ๋ฆฌ์ ๋ฐ๋ฅธ LOD(Level of Detail) ๊ธธ์ฐพ๊ธฐ ๊ตฌํ
ํ๋ฉด(Viewport) ๋ฐ์ ์๊ฑฐ๋ ํ๋ ์ด์ด์ ๋ฉ๋ฆฌ ๋จ์ด์ง AI ์์ด์ ํธ๋ ๊ณ ์ ๋ฐ A* ์ฐ์ฐ์ ๋งค๋ฒ ์ํํ ํ์๊ฐ ์์ต๋๋ค.
- Near Zone (ํ๋ ์ด์ด ๊ทผ์ฒ): 0.2~0.5์ด ์ฃผ๊ธฐ๋ก ๋น๋๊ธฐ ๊ฒฝ๋ก ์ ๋ฐ์ดํธ. RVO2 ํํผ ์ฐ์ฐ ํ์ฑํ.
- Far Zone (ํ๋ฉด ๋ฐ): 2.0~3.0์ด ์ฃผ๊ธฐ๋ก ์ ๋ฐ์ดํธ ์ฃผ๊ธฐ๋ฅผ ๋ํญ ๋ฎ์ถค. ํํผ ์ฐ์ฐ ๋นํ์ฑํ ๋ฐ ์ง์ ๊ฑฐ๋ฆฌ ๋ฒกํฐ ๋จ์ ์ด๋(Direct Vector Movement) ํผ์ฉ.
4. ์ฑ๋ฅ ์ต์ ํ ์ ํ ๋ฒค์น๋งํฌ ๋น๊ต
์๋ ํ๋ ๋์ผํ PC ํ๊ฒฝ(Intel i7-12700K, RTX 3070)์์ 800๊ฐ์ 2D ์์ด์ ํธ๊ฐ ๋์์ ํ๋ ์ด์ด๋ฅผ ์ถ์ ํ๋๋ก ํ ์คํธํ ๊ฒฐ๊ณผ์ ๋๋ค.
| ๊ตฌ๋ถ | ๊ธฐ์กด (๋๊ธฐ์ + ๊ธฐ๋ณธ ์ธํ ) | ์ต์ ํ ํ (๋น๋๊ธฐ ์ค๋ ๋ + LOD) |
|---|---|---|
| ํ๊ท ํ๋ ์ (FPS) | 18 ~ 24 FPS | 58 ~ 60 FPS |
| Physics Frame Time | 42.5 ms | 6.2 ms |
| CPU ๋ฉ์ธ ์ค๋ ๋ ์ ์ ์จ | 98% (๋จ์ผ ์ฝ์ด ๊ณผ๋ถํ) | 35% (๋ฉํฐ ์ฝ์ด ๋ถ์ฐ) |
| ๋ง์ดํฌ๋ก ์คํฐํฐ๋ง | ๋งค์ฐ ์ฌํจ (๊ฒฝ๋ก ์ฌ๊ณ์ฐ ์) | ๊ด์ฐฐ๋์ง ์์ |
5. ๊ฒฐ๋ก ๋ฐ ํต์ฌ ์์ฝ
Godot 4์์ 2D ๋๊ท๋ชจ ๊ธธ์ฐพ๊ธฐ ๋ณ๋ชฉ ํ์์ ํด๊ฒฐํ๋ ํต์ฌ ์์๋ "์ฐ์ฐ์ ๋น๋๊ธฐํ"์ "๋ถํ์ํ ๊ณ์ฐ์ ์ต์ํ"์ ๋๋ค.
NavigationServer2D.map_get_path()์ง์ ํธ์ถ์ ๋ฉ์ธ ์ค๋ ๋์์ ๋ฐฐ์ ํ๊ณWorkerThreadPool๊ธฐ๋ฐ์ผ๋ก ๋น๋๊ธฐ ์ฒ๋ฆฌํ์ธ์.- ๋ชจ๋ ์์ด์ ํธ์ ๊ฒฝ๋ก๋ฅผ ๊ฐ์ ํ๋ ์์ ๊ตฌํ์ง ์๋๋ก ํ์ ์ฌ๋ผ์ด์ฑ(Time-Slicing)์ ์ ์ฉํ์ธ์.
- RVO2 ํํผ ์ฐ์ฐ์ ์๊ฐ์ ์ผ๋ก ์ค์ํ ๊ฐ์ฒด์๋ง ์ ํ์ ์ผ๋ก ์ ์ฉํ๊ณ , ์๋ง์ ์ก๋ชน์ Distance LOD ๊ธฐ๋ฒ๊ณผ ๋จ์ ๋ฌผ๋ฆฌ ํ์ค(Impulse) ๋ถ๋ฆฌ๋ก ๋์ฒดํ์ธ์.
- ๋์ ๋งต ๋ณ๊ฒฝ ์ ๋ฐ์ํ ์ ์๋ ์ค๋ ๋ ์์ ์ฑ(Thread-Safety) ์ด์๋ฅผ ๋๋นํด Task Queue Flush ๋งค์ปค๋์ฆ์ ๋ฐ๋์ ๊ฐ์ถ์ด์ผ ํฉ๋๋ค.
์ด๋ฌํ ์ต์ ํ ๊ธฐ๋ฒ ๊ธฐํ์ ์ธ์๋๋ค๋ฉด, ์์ฒ ๊ฐ์ ๊ตฐ์ง AI๊ฐ ๋ฑ์ฅํ๋ 2D ํ๋ค์ด ๊ฒ์์์๋ ํ๋ ์ ์ ํ ์์ด ๋งค๋๋ฝ๊ณ ๋ชฐ์ ๊ฐ ๋์ ํ๋ ์ด ๊ฒฝํ์ ์ ๊ณตํ ์ ์์ ๊ฒ์ ๋๋ค.