๐ English Abstract
This technical article explores the root causes of frame stuttering caused by runtime shader compilation in Godot 4's Vulkan-based rendering pipeline. It details practical troubleshooting strategies, including shader pipeline state object (PSO) caching and asynchronous shader pre-warming techniques using custom off-screen viewport passes. By implementing these optimizations, developers can eliminate micro-stutters and achieve smooth, production-ready frame rates across diverse GPU architectures.
Godot 4 ์์ง์ผ๋ก ํ๋ก์ ํธ๋ฅผ ์ ํํ๊ฑฐ๋ ์๋กญ๊ฒ ๊ฒ์์ ๊ฐ๋ฐํ ๋, ๋ง์ ๊ฐ๋ฐ์๊ฐ ๊ฐ์ฅ ๋จผ์ ์ง๋ฉดํ๋ ๊ธฐ์ ์ ๋๊ด ์ค ํ๋๊ฐ ๋ฐ๋ก ์ค์๊ฐ ์ ฐ์ด๋ ์ปดํ์ผ ์ง์ฐ(Shader Compilation Stuttering)์ ๋๋ค. ํ๋ ์ด์ด๊ฐ ์๋ก์ด ๋ชฌ์คํฐ๋ฅผ ์ฒ์ ๋ง์ฃผ์น๊ฑฐ๋, ์ดํํธ(Particle System)๊ฐ ๋ฐ์ฌ๋ ๋ ํ๋ฉด์ด ์์ญ ๋ฐ๋ฆฌ์ด(ms) ๋์ ๋ฉ์นซํ๋ ํ์์ ๊ฒ์์ ๋ชฐ์ ๊ฐ์ ๊ทน๋๋ก ํด์นฉ๋๋ค.
์ด ๊ธ์์๋ Godot 4์ Forward+ ๋ฐ Mobile ๋ฐฑ์๋(Vulkan/RenderingDevice) ์ํคํ ์ฒ ๋ด๋ถ์์ ์ ์ด๋ฌํ ํ๋ ์ ๋๋์ด ๋ฐ์ํ๋์ง ๊ทผ๋ณธ์ ์ธ ์์ธ์ ๋ถ์ํ๊ณ , ์ด๋ฅผ ์๋ฒฝํ ์ ๊ฑฐํ๊ธฐ ์ํ ์ ฐ์ด๋ ํ์ดํ๋ผ์ธ ์บ์ฑ(Shader Pipeline Caching) ๊ธฐ๋ฒ๊ณผ ์ ฐ์ด๋ ์๋ฐ์ (Shader Warmup / Pre-warming) ์์คํ ๊ตฌ์ถ ๋ฐฉ์์ ๊น์ด ์๊ฒ ๋ค๋ฃน๋๋ค.
1. Godot 4 ๋ ๋๋ง ํ์ดํ๋ผ์ธ๊ณผ ์ ฐ์ด๋ ์ปดํ์ผ ์คํฐํฐ๋ง์ ์์ธ
Godot 3์ OpenGL ES ๊ธฐ๋ฐ ๋ ๋๋ฌ์ ๋ฌ๋ฆฌ, Godot 4๋ ํ๋์ ์ธ Vulkan API๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ RenderingDevice ์ํคํ
์ฒ๋ฅผ ์ฑํํ์ต๋๋ค. Vulkan์ ๊ทธ๋ํฝ ๋๋ผ์ด๋ฒ์ ์ค๋ฒํค๋๋ฅผ ์ต์ํํ๊ณ ๋ฉํฐ์ค๋ ๋ฉ ์ฑ๋ฅ์ ๊ทน๋ํํ ์ ์๋ ์ฅ์ ์ด ์์ง๋ง, ๊ทธ ๋๊ฐ๋ก ํ์ดํ๋ผ์ธ ์ํ ๊ฐ์ฒด(PSO: Pipeline State Object)์ ๊ด๋ฆฌ๋ฅผ ์์ง ์ธก์์ ์ง์ ์ ๋ดํด์ผ ํฉ๋๋ค.
ํ์ดํ๋ผ์ธ ์ํ ๊ฐ์ฒด(PSO) ์ฐ์ฐ์ ๋ณ๋ชฉ
Vulkan์์ ์ ฐ์ด๋๋ฅผ ์คํํ๋ ค๋ฉด ๊ทธ๋ํฝ ์นด๋๊ฐ ์ดํดํ ์ ์๋ ์์ ํ ํ์ดํ๋ผ์ธ ์ํ๊ฐ ์ ์๋์ด์ผ ํฉ๋๋ค. PSO๋ ๋จ์ํ ์ ฐ์ด๋ ์ฝ๋(SPIR-V)๋ฟ๋ง ์๋๋ผ ๋ค์๊ณผ ๊ฐ์ ๋ ๋ ์ํ ์ ๋ณด๊ฐ ๋ชจ๋ ๊ฒฐํฉ๋์ด ์์ฑ๋ฉ๋๋ค:
- ์ ์ ์ ฐ์ด๋ ๋ฐ์ดํธ์ฝ๋(Vertex/Fragment Shader Code)
- ์ ๋ ฅ ๋ฒํ ์ค ๋ ์ด์์(Vertex Input Bindings & Attributes)
- ๋ธ๋ ๋ฉ ๋ชจ๋(Alpha Blend, Additive Blend ๋ฑ)
- ์ปฌ๋ง ๋ฐ ๊น์ด/์คํ ์ค ์ํ(Culling Mode, Depth Test/Write Options)
- ๋ ๋ ํ๊ฒ์ ๋ฉํฐ์ํ๋ง(MSAA) ์ ๋ณด ๋ฐ ์์ ํฌ๋งท
๋ฌธ์ ๋ ์ด๋ฌํ ์กฐํฉ์ด ์ค์๊ฐ ๋ ๋๋ง ํ์์ ํ๋ฉด์ "์ฒ์ ๊ทธ๋ ค์ง๋ ์๊ฐ"์ ๋น๋ก์ ํ์ ๋๋ค๋ ์ ์ ๋๋ค. ์์ง์ด ์๋ก์ด ์กฐํฉ์ PSO๋ฅผ ๊ฐ์งํ๋ฉด ๊ทธ๋ํฝ ๋๋ผ์ด๋ฒ๋ ๋๋ผ์ด๋ฒ ๋ ๋ฒจ์์ ๊ธฐ๊ณ์ด(Machine Code)๋ก ์ ฐ์ด๋๋ฅผ ์ปดํ์ผํ๊ฒ ๋๋ฉฐ, ์ด ์์ ์ ๋ฉ์ธ ๋ ๋๋ง ์ค๋ ๋๋ฅผ ์ฐจ๋จ(Blocking)ํ์ฌ ์์ญ~์๋ฐฑms์ ๋ฌํ๋ Spike๋ฅผ ์ ๋ฐํฉ๋๋ค.
๐ก ํต์ฌ ์์ฝ: ์ ฐ์ด๋ ํ์ผ(.gdshader) ์์ฒด๊ฐ ๋ก๋๋๋ ์๊ฐ์ด ์๋๋ผ, ํน์ ๋จธํฐ๋ฆฌ์ผ(Material)๊ณผ ๋ ๋ ์ํ(Render State)๊ฐ ๊ฒฐํฉ๋์ด GPU ํ์ดํ๋ผ์ธ(PSO)์ ์ฒ์ ์ ์ถ(Submit)๋ ๋ ์ปดํ์ผ ์ง์ฐ์ด ๋ฐ์ํฉ๋๋ค.
2. Godot 4์ ๊ธฐ๋ณธ ํ์ดํ๋ผ์ธ ์บ์ฑ ๋ฉ์ปค๋์ฆ๊ณผ ํ๊ณ
Godot 4๋ ์ด๋ฌํ ๋ฌธ์ ๋ฅผ ์ํํ๊ธฐ ์ํด ์์ฒด์ ์ธ ์
ฐ์ด๋ ์บ์(Shader Cache) ๊ธฐ๋ฅ์ ํ์ฌํ๊ณ ์์ต๋๋ค. ํ๋ก์ ํธ ์ค์ (Project Settings)์์ rendering/shader_compiler/shader_cache/enabled ์ต์
์ ์ผ๋๋ฉด, ์์ง์ ์ด์ ์ ์ปดํ์ผ๋ ์
ฐ์ด๋ ํ์ดํ๋ผ์ธ ๋ฐ์ด๋๋ฆฌ๋ฅผ ๋์คํฌ์ ์ ์ฅํฉ๋๋ค.
๋์คํฌ ์บ์์ ํ๊ณ์ ๋ฐ ํธ๋ฌ๋ธ์ํ ์ผ์ด์ค
๊ธฐ๋ณธ ๋์คํฌ ์บ์ฑ ๊ธฐ๋ฅ๋ง์ผ๋ก๋ ํ์ ํ๋ก์ ํธ์ ์คํฐํฐ๋ง์ ์์ ํ ์๋ฐฉํ ์ ์์ต๋๋ค. ๋ค์๊ณผ ๊ฐ์ ์ฃ์ง ์ผ์ด์ค(Edge Cases)๊ฐ ์กด์ฌํ๊ธฐ ๋๋ฌธ์ ๋๋ค:
- ์ต์ด ์คํ์(First-Time Users) ๋ฌธ์ : ๊ฒ์ด๋จธ๊ฐ ๊ฒ์์ ์ค์นํ๊ณ ์ฒ์ ํ๋ ์ดํ ๋ ๋์คํฌ ์บ์๋ ๋น์ด์์ต๋๋ค. ๋ฐ๋ผ์ ๊ฒ์ ์ด๋ฐ ํ๋ ์ด ์ ์คํฐํฐ๋ง์ ํผํ ์ ์์ต๋๋ค.
- GPU ๋๋ผ์ด๋ฒ ์ ๋ฐ์ดํธ ๋ฐ ํ๋์จ์ด ๋ณ๊ฒฝ: ๊ทธ๋ํฝ ์นด๋ ๋๋ผ์ด๋ฒ๊ฐ ์ ๋ฐ์ดํธ๋๋ฉด ๊ธฐ์กด ๋์คํฌ ์บ์๋ ๋ฌดํจํ(Invalidated)๋์ด ์ ๋ ์ฌ์ปดํ์ผ๋ฉ๋๋ค.
- ๋์ ๋จธํฐ๋ฆฌ์ผ ๋ฐ์ธ๋ฉ: ๋ฐํ์์ ๋จธํฐ๋ฆฌ์ผ ํ๋ผ๋ฏธํฐ๋ฅผ ๋์ ์ผ๋ก ๋ณ๊ฒฝํ์ฌ ์๋ก์ด ์ ฐ์ด๋ ๋ณ์ข (Variant)์ด ํธ๋ฆฌ๊ฑฐ๋๋ ๊ฒฝ์ฐ, ์ค์๊ฐ ์ปดํ์ผ์ด ๋ฐ์ํฉ๋๋ค.
3. ์ค์ ํด๋ฒ: ์คํ์คํฌ๋ฆฐ ๋ทฐํฌํธ๋ฅผ ํ์ฉํ ์ ฐ์ด๋ ์๋ฐ์ (Warmup) ๊ตฌ์ถ
๊ฐ์ฅ ์๋ฒฝํ ํด๊ฒฐ์ฑ ์ ๊ฒ์ ํ๋ ์ด ์ฌ์ผ๋ก ์ ํ๋๊ธฐ ์ , ๊ฒ์ ๋ด ์ฌ์ฉ๋๋ ๋ชจ๋ ๋จธํฐ๋ฆฌ์ผ๊ณผ ํํฐํด, ๋ฉ์ ์กฐํฉ์ ํ๋ฉด์ ๋ณด์ด์ง ์๋ ๋ ๋ ํ๊ฒ(Off-screen Viewport)์ ๋ฏธ๋ฆฌ ํ ๋ฒ ๋ ๋๋งํ์ฌ GPU PSO๋ฅผๅผทๅถ๋ก ์์ฑ ๋ฐ ์บ์ฑํ๋ ๊ฒ์ ๋๋ค.
์๋ํ๋ ์ ฐ์ด๋ Pre-warming ์์คํ ๊ตฌํ
๋ค์์ ๋ก๋ฉ ํ๋ฉด ๋จ๊ณ์์ ์ฃผ์ ์ ฐ์ด๋์ ํํฐํด ์์คํ ์ 1ํ๋ ์ ๋์ ๊ฐ์ ๊ตฌ๋ํ๋ GDScript ์ค๋ฌด ์์ ์ฝ๋์ ๋๋ค.
# ShaderWarmupManager.gd
extends Node
class_name ShaderWarmupManager
signal warmup_completed
@export var materials_to_warmup: Array[Material] = []
@export var mesh_to_use: Array[Mesh] = []
var _sub_viewport: SubViewport
var _camera: Camera3D
func _ready() -> void:
_setup_offscreen_viewport()
func _setup_offscreen_viewport() -> void:
# ์คํ์คํฌ๋ฆฐ ๋ทฐํฌํธ ์์ฑ (ํ๋ฉด์ ํ์๋์ง ์์)
_sub_viewport = SubViewport.new()
_sub_viewport.size = Vector2i(64, 64) # ์ต์ ํฌ๊ธฐ๋ก ๋ฉ๋ชจ๋ฆฌ ์ ์ฝ
_sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
_camera = Camera3D.new()
_camera.transform.origin = Vector3(0, 0, 5)
_sub_viewport.add_child(_camera)
add_child(_sub_viewport)
func execute_warmup() -> void:
if materials_to_warmup.is_empty():
warmup_completed.emit()
return
var container = Node3D.new()
_sub_viewport.add_child(container)
# ๋ฑ๋ก๋ ๋ชจ๋ ๋จธํฐ๋ฆฌ์ผ์ ๋๋ฏธ ๋ฉ์์ ์ ์ฉํ์ฌ ๋ทฐํฌํธ์ ์ถ๊ฐ
for i in range(materials_to_warmup.size()):
var mat = materials_to_warmup[i]
var mesh_instance = MeshInstance3D.new()
# ๋ฉ์ ํ์
์ง์ (๊ธฐ๋ณธ๊ฐ: QuadMesh)
if i < mesh_to_use.size() and mesh_to_use[i] != null:
mesh_instance.mesh = mesh_to_use[i]
else:
var quad = QuadMesh.new()
mesh_instance.mesh = quad
mesh_instance.material_override = mat
mesh_instance.position = Vector3(0, 0, 0) # ์นด๋ฉ๋ผ์ธ ์์ผ ๋ด ๋ฐฐ์น
container.add_child(mesh_instance)
# ๋ ๋๋ง ์ ์ถ ๊ฐ์ ํธ๋ฆฌ๊ฑฐ
_sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
# RenderingServer์ ๋ ๋๋ง ํ ์ฒ๋ฆฌ๋ฅผ ๊ธฐ๋ค๋ฆผ
await RenderingServer.frame_post_draw
# ์ ๋ฆฌ ์์
container.queue_free()
print("[ShaderWarmup] %d๊ฐ ๋จธํฐ๋ฆฌ์ผ ํ์ดํ๋ผ์ธ ์ฌ์ ์ปดํ์ผ ์๋ฃ" % materials_to_warmup.size())
warmup_completed.emit()
ํํฐํด ์ ฐ์ด๋(GPUParticles3D)์ ์๋ฐ์ ํน์ ์ฒ๋ฆฌ
์ผ๋ฐ ๋ฉ์ฌ ์
ฐ์ด๋์ ๋ฌ๋ฆฌ GPUParticles3D ๋ฐ GPUParticles2D๋ ์
ฐ์ด๋ ์ปดํ์ผ ์ธ์๋ ํํฐํด ์๋ฎฌ๋ ์ด์
์ํ(Compute/Process Shader)๊ฐ ์ต์ด ๋ฐ์ฌ ์ ์ปดํ์ผ๋ฉ๋๋ค. ๋ฐ๋ผ์ ํํฐํด ๋จธํฐ๋ฆฌ์ผ์ ๊ฒฝ์ฐ emitting = true ์ํ๋ก ์คํ์คํฌ๋ฆฐ ๋ทฐํฌํธ์ ์ต์ 1ํ๋ ์ ์ด์ ๋ ๋๋ง์ ์์ผ์ผ ํฉ๋๋ค.
func warmup_particles(particle_scenes: Array[PackedScene]) -> void:
var temp_node = Node3D.new()
_sub_viewport.add_child(temp_node)
for scene in particle_scenes:
var p_instance = scene.instantiate()
if p_instance is GPUParticles3D:
temp_node.add_child(p_instance)
p_instance.emitting = true
p_instance.restart()
_sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
await RenderingServer.frame_post_draw
temp_node.queue_free()
4. ์ฑ๋ฅ ์ต์ ํ ๊ธฐ๋ฒ ๋ฐ ํ์ ์ฃ์ง ์ผ์ด์ค ๋์
์ ฐ์ด๋ ์๋ฐ์ ๊ธฐ๋ฒ์ ๋์ ํ ๋ ์ฃผ์ํด์ผ ํ ๊ณ ๊ธ ๊ธฐ์ ์ฌ์๊ณผ ์ฃ์ง ์ผ์ด์ค๋ค์ ์ ๋ฆฌํฉ๋๋ค.
โ ์ ์ญ ๋จธํฐ๋ฆฌ์ผ ์์ง ์๋ํ (Asset Pipeline Engine Extension)
ํ๋ก์ ํธ ๋ด ์๋ฐฑ ๊ฐ์ ๋จธํฐ๋ฆฌ์ผ์ ์์ผ๋ก ๋ฐฐ์ด์ ํ ๋นํ๋ ๊ฒ์ ์ ์ง๋ณด์ ์ธก๋ฉด์์ ๋ถ๊ฐ๋ฅ์ ๊ฐ๊น์ต๋๋ค. EditorPlugin์ ํ์ฅํ๊ฑฐ๋ ๋น๋ ๋จ๊ณ์์ ProjectSettings ๋ชฉ๋ก์ ๋ํํ์ฌ res:// ๋๋ ํ ๋ฆฌ ๋ด๋ถ์ ๋ชจ๋ .tres, .material ๋ฆฌ์์ค๋ฅผ ๊ฒ์ํด ์๋์ผ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์คํํ๋ ์คํฌ๋ฆฝํธ๋ฅผ ๋น๋ ํ์ดํ๋ผ์ธ์ ํฌํจํ๋ ๊ฒ์ด ๊ถ์ฅ๋ฉ๋๋ค.
โก ๋์ ์ ฐ์ด๋ ๋ถ๊ธฐ(Dynamic Shader Branching) ๊ด๋ฆฌ
#ifdef ์กฐ๊ฑด๋ถ ์ปดํ์ผ์ด๋ Godot Shader ์ธ์ด์ ๊ธฐ๋ฅ ํ๋๊ทธ(Feature Flags)(์: render_mode use_depth_prepass, depth_draw_opaque)๋ฅผ ์ฌ์ฉํ๋ ์
ฐ์ด๋๋ ํ๋๊ทธ ์ํ๊ฐ ๋ฐ๋ ๋๋ง๋ค ์๋ก ๋ค๋ฅธ PSO๋ฅผ ๋น๋ํฉ๋๋ค. ์๋ฐ์
๋จ๊ณ์์ ์ด๋ฌํ ์ต์
์กฐํฉ๋ณ๋ก ๋ ๋๋ง ํ
์คํธ๋ฅผ ์ ํํด์ผ๋ง ๋ฐํ์ ํ๋ ์ ๋๋์ ๋ง์ ์ ์์ต๋๋ค.
โข ์๋๋ก์ด๋(Android) ๋ฐ ๋ชจ๋ฐ์ผ Vulkan ๋๋ผ์ด๋ฒ์ ์บ์ ํน์ฑ
๋ชจ๋ฐ์ผ GPU(Qualcomm Adreno, ARM Mali ๋ฑ)๋ ํ
์ค์ฒ ํฌ๋งท(ASTC, ETC2)๊ณผ MSAA ์ค์ ๋ณ๊ฒฝ์ ๋ฐ๋ฅธ PSO ์ปดํ์ผ ๋น์ฉ์ด PC ๊ทธ๋ํฝ ์นด๋๋ณด๋ค ํจ์ฌ ํฝ๋๋ค. ๋ชจ๋ฐ์ผ ํ๊ฒํ
ํ๋ก์ ํธ๋ผ๋ฉด rendering/vulkan/pipeline_cache/save_chunk_size ์ต์
์ ์กฐ์ ํ๊ณ ์ฑ ๊ตฌ๋ ์ **์คํ๋์ ์คํฌ๋ฆฐ(Splash Screen)**์ ๋์ด ์ํ์์ ๋ฐฑ๊ทธ๋ผ์ด๋๋ก ์
ฐ์ด๋ ์ปดํ์ผ ํจ์ค๋ฅผ ์กฐ์ฉํ ๊ตฌ๋ํ๋ ๊ตฌ์กฐ๋ฅผ ๊ตฌ์ถํด์ผ ํฉ๋๋ค.
5. ๊ฒฐ๋ก ๋ฐ ์์ฝ
Godot 4 ๋ ๋๋ง ์์ง์ Vulkan ๋์ ์ ๊ทธ๋ํฝ์ ํํ๋ ฅ๊ณผ ๋ฉํฐ์ค๋ ๋ ์ฑ๋ฅ์ ๋ํญ ํฅ์์์ผฐ์ง๋ง, PSO ์ปดํ์ผ ์ง์ฐ์ผ๋ก ์ธํ ์ค์๊ฐ ํ๋ ์ ์คํฐํฐ๋ง์ด๋ผ๋ ๋ช ํํ ๊ณผ์ ๋ฅผ ์๊ฒจ์ฃผ์์ต๋๋ค. ์ด ๋ฌธ์ ๋ฅผ ๊ทผ๋ณธ์ ์ผ๋ก ๊ทน๋ณตํ๊ธฐ ์ํด์๋ ์๋ ์ธ ๊ฐ์ง ์์๋ฅผ ๋ฐ๋์ ์ ์ฉํด์ผ ํฉ๋๋ค.
- ๋์คํฌ ์ ฐ์ด๋ ์บ์ฑ ํ์ฑํ: Project Settings์์ ์ ฐ์ด๋ ์บ์ ์ต์ ๊ฒ์ฆ ๋ฐ ์ ์ฉ.
- ์คํ์คํฌ๋ฆฐ ๋ทฐํฌํธ ์๋ฐ์ ์์คํ ๋์ : ๋ก๋ฉ ์ฌ์์ ๋ฉ์ฌ, ๋จธํฐ๋ฆฌ์ผ, ํ ์ค์ฒ, GPUParticles3D๋ฅผ ์ต์ 1ํ๋ ์ ์ฌ์ ์ ์ถ(Pre-pass Submit).
- ๋์ ๋จธํฐ๋ฆฌ์ผ ์ํ ๊ด๋ฆฌ: ๋ฐํ์์ ๋ฐ์ ๊ฐ๋ฅํ ์ ฐ์ด๋ ๋ณ์ข ์กฐํฉ์ ์ต์ํํ๊ณ , ์ด๊ธฐํ ์์ ์ ๋ ๋ ํ์ดํ๋ผ์ธ ์์ฑ ์๋ฃ.
์ ํธ๋ฌ๋ธ์ํ ํจํด์ ํ๋ก์ ํธ ์ํคํ ์ฒ์ ๋ด์ฌํํ๋ฉด, ๊ณ ๋ํ๋ 3D ์ฌ๊ณผ ํ๋ คํ VFX ์ดํํธ ์์์๋ 60 FPS ์ด์์ ๋งค๋๋ฝ๊ณ ์ผ์ ํ ๋ ๋๋ง ์ฑ๋ฅ์ ํ๋ณดํ ์ ์์ต๋๋ค.