Godot 4에서 FPS를 지키는 숲 만들기: MultiMesh, LOD, Visibility Range
Godot 4에서 가벼운 식생을 만드는 실전 가이드: 폴리곤 예산, 자동 메시 LOD와 Visibility Range 비교, 청크 단위 MultiMesh, 잎 투명도, 그림자, 오클루전 컬링, 그리고 결과를 측정하는 방법까지.

Godot 프로젝트의 첫 번째 숲은 보통 멋져 보입니다. 그 안으로 걸어 들어가기 전까지는요. 나무를 200그루쯤 심고 나면 프레임 레이트가 반토막 나고, 프로파일러에는 수천 개의 드로우 콜이 찍히는데, 어떤 설정을 바꿔야 할지는 아무도 모릅니다. Godot 포럼의 한 사용자가 바로 이런 상황을 전했습니다. 직접 만든 풀 LOD 시스템이 작은 땅 한 뙈기에서 약 10 FPS, 드로우 콜 3,000개 이상이라는 결과를 냈다고 합니다.
다행히 Godot 4에는 넓고 빠른 숲을 만드는 데 필요한 도구가 모두 있습니다. 올바르게 조합하기만 하면 됩니다. 이 가이드는 중요한 순서대로—예산, LOD, 인스턴싱, 잎, 그림자, 컬링—하나씩 살펴보고 마지막에 체크리스트로 정리합니다.
숲이 느린 이유
나무는 게임에서 가장 비싼 오브젝트 중 하나입니다. 네 군데에서 동시에 비용이 듭니다.
- 드로우 콜. 나무 한 그루는 최소 두 개의 메시(껍질과 잎)로 이루어지고, 머티리얼이 다른 경우도 많습니다. 따로 놓인 나무 100그루면 다른 걸 하나도 그리기 전에 이미 드로우 콜이 수백 개입니다.
- 폴리곤과 버텍스. 디테일한 나무는 40,000 폴리곤을 쉽게 넘깁니다. 주인공 나무 한 그루라면 괜찮지만, 산비탈 전체라면 치명적입니다.
- 오버드로. 잎은 투명 픽셀이 있는 카드입니다. GPU는 보이지 않는 부분까지 셰이딩하며, 수관의 모든 카드에 대해 층층이 이를 반복합니다.
- 그림자. 모든 나무가 섀도 맵에 한 번 더 그려집니다. 섀도 캐스케이드마다 한 번씩, 여러 번 그려지기도 합니다.
아래의 각 기법은 이 비용 중 하나 이상을 공략합니다.
1단계. 만들기 전에 예산부터 정하기
가장 유용한 습관은 모델링이나 생성 전에 거리별로 나무 한 그루가 쓸 수 있는 폴리곤 수를 정해 두는 것입니다. 게임에 바로 쓸 수 있는 LOD 체인이 어떤 모습인지, FoliMesh의 실제 참나무를 예로 들어 보겠습니다.
| 레벨 | 거리(예시) | 폴리곤 |
|---|---|---|
| LOD0 | 0–30 m | 14,490 |
| LOD1 | 30–70 m | 3,984 |
| LOD2 | 70–150 m | 1,154 |
| 임포스터 | 150 m + | 2 |
중요한 건 정확한 숫자가 아니라 곡선의 모양입니다. 각 레벨은 이전 레벨보다 대략 3~4배 가볍고, 가장 먼 나무는 거의 비용이 들지 않습니다. 모바일이라면 전부 2~3으로 나누세요. 숲의 대부분은 멀리 있으니, 숲의 대부분은 저렴해야 합니다.
같은 나무의 네 가지 디테일 레벨: 실루엣은 유지되고 폴리곤 수는 줄어듭니다.
2단계. LOD 방식 선택: 자동 메시 LOD냐, Visibility Range냐
Godot 4에는 서로 다른 두 가지 LOD 시스템이 있고, 나무에 엉뚱한 쪽을 고르는 것이 흔한 문제의 원인입니다.
자동 메시 LOD. glTF, .blend, FBX 씬을 임포트하면 Godot은 meshoptimizer로 각 메시의 단순화 버전을 만들고 알아서 전환합니다. 공식 문서에 따르면, 전환 기준은 노드 바운딩 박스에서 카메라와 가장 가까운 점, 그리고 오차가 몇 픽셀을 차지하는지입니다(Rendering → Mesh LOD → LOD Change → Threshold Pixels, 기본값 1픽셀). 각 노드에는 LOD Bias도 있습니다.
이 방식은 바위, 건물, 소품처럼 연속된 표면에는 잘 맞습니다. 하지만 나무 수관은 다릅니다. 수백 장의 개별 잎 카드로 이루어져 있죠. 범용 단순화는 이런 형태를 염두에 두고 만들어지지 않았기 때문에, 수관이 듬성듬성해지거나 일그러질 수 있습니다. 문서에서도 직접 만든 LOD가 있다면 다른 도구를 쓰라고 권합니다.
Visibility Range(HLOD). 이 방식은 LOD 메시를 직접 제공하고, 각각이 어느 거리에서 보일지 지정합니다. 모든 GeometryInstance3D에는 다음 속성이 있습니다.
visibility_range_begin/visibility_range_end— 노드가 그려지는 거리 구간(바운딩 박스 중심까지 측정);visibility_range_begin_margin/visibility_range_end_margin— 카메라가 경계에 딱 걸쳐 있을 때 레벨이 깜빡이지 않도록 하는 버퍼 구간;visibility_range_fade_mode— Disabled(즉시 전환, 가장 빠름), Self 또는 Dependencies(알파 페이드. 전환 중에는 투명 렌더링이 강제되어 성능 비용이 듭니다).
나무에는 Visibility Range가 믿을 만한 선택입니다. LOD가 식생용으로 설계되고, 거리를 직접 제어할 수 있으며, 마지막 레벨에서 그림자를 끄거나 임포스터로 바꿀 수 있습니다. 나무 한 그루의 최소 설정은 다음과 같습니다.
# Tree.gd — 자식 노드: LOD0, LOD1, LOD2 (MeshInstance3D)
extends Node3D
@export var lod_starts := PackedFloat32Array([0.0, 30.0, 70.0])
@export var cull_distance := 150.0
func _ready() -> void:
for i in get_child_count():
var mi := get_child(i) as MeshInstance3D
if mi == null:
continue
mi.visibility_range_begin = lod_starts[i]
mi.visibility_range_end = lod_starts[i + 1] if i + 1 < lod_starts.size() else cull_distance
mi.visibility_range_end_margin = 2.0
mi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_DISABLED
전환이 눈에 띈다면, 먼저 LOD들이 같은 실루엣을 유지하는지 확인하세요. 어떤 페이드 설정보다 그게 더 중요합니다.
3단계. 나무가 많다면: MultiMesh, 단 청크로 나눠서
나무가 수십 그루라면 개별 노드로 충분합니다. 수천 그루라면 MultiMesh가 필요합니다. MultiMeshInstance3D 하나로 메시 복사본을 몇 개든 드로우 콜 한 번에 그릴 수 있습니다.
그런데 거의 모두가 놀라는 함정이 있습니다. 문서에 따르면 많은 메시를 한꺼번에 그리는 노드—MultiMeshInstance3D와 파티클—는 모든 인스턴스를 같은 LOD 레벨로 그리며, 컬링도 노드 전체 단위로만 가능하고 개별 인스턴스는 제거할 수 없습니다. Godot 메인테이너도 issue #76436에서 이를 확인했습니다. 레벨 전체에 MultiMesh 하나를 쓰면 나무별 LOD도, 프러스텀 컬링이나 오클루전 컬링도 없다는 뜻입니다.
해결책은 청크입니다. 월드를 격자로 나누고, 각 칸마다 LOD 레벨별 MultiMesh를 따로 두되 각각 고유한 Visibility Range를 줍니다. 먼 청크는 가벼운 LOD를, 가까운 청크는 디테일한 LOD를 보여 주고, 카메라 뒤의 청크는 통째로 컬링됩니다.
# ForestChunks.gd — Visibility Range가 적용된 청크 단위 MultiMesh 생성
extends Node3D
@export var lod_meshes: Array[Mesh] = [] # LOD0, LOD1, LOD2, 임포스터
@export var lod_starts := PackedFloat32Array([0.0, 35.0, 80.0, 160.0])
@export var cull_distance := 400.0
@export var chunk_size := 32.0
func build(transforms: Array[Transform3D]) -> void:
var chunks := {}
for t in transforms:
var key := Vector2i(floori(t.origin.x / chunk_size), floori(t.origin.z / chunk_size))
if not chunks.has(key):
chunks[key] = []
chunks[key].append(t)
for key in chunks:
_build_chunk(chunks[key])
func _build_chunk(items: Array) -> void:
for i in lod_meshes.size():
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.mesh = lod_meshes[i]
mm.instance_count = items.size()
for k in items.size():
mm.set_instance_transform(k, items[k])
var node := MultiMeshInstance3D.new()
node.multimesh = mm
node.visibility_range_begin = lod_starts[i]
node.visibility_range_end = lod_starts[i + 1] if i + 1 < lod_starts.size() else cull_distance
node.visibility_range_end_margin = 4.0
add_child(node)
실전 규칙 두 가지:
- 청크 크기는 LOD0 거리에 가깝게. 거리는 청크 중심까지 측정되므로, 100 m 청크라면 플레이어 바로 옆의 나무가 LOD1로 보이게 됩니다. LOD0가 35 m까지라면 24~32 m 청크가 잘 맞습니다.
- 그렇다고 너무 작게도 말고. 청크마다 드로우 콜이 늘어납니다(LOD마다, 머티리얼마다 하나씩). 측정해 보세요. 보통 화면에 청크 수백 개 정도는 데스크톱에서 괜찮지만, 모바일에서는 훨씬 적어야 합니다.
MultiMesh에는 콜리전이 없습니다. 플레이어가 닿을 수 있는 곳에만 단순한 형태를 추가하세요. 줄기마다 캡슐이나 실린더 하나, 수관에는 절대 넣지 마세요.
인스턴싱된 나무 수백 그루: 구조만 올바르면 숲이 커져도 드로우 콜은 거의 늘지 않습니다.
4단계. 잎: 알파 블렌딩 말고 Alpha Scissor
대부분의 숲은 잎에서 성능을 잃습니다. 투명도를 잘못 설정하면 아름다움도 상당 부분 잃고요.
- Alpha 말고 Alpha Scissor(컷아웃)를 쓰세요. 머티리얼에서 Transparency → Alpha Scissor로 설정합니다. 블렌딩 알파는 정렬이 필요하고, 깊이와 그림자를 망가뜨리며, 수백 장의 카드가 겹치는 수관에서는 훨씬 비쌉니다.
- Alpha to Coverage로 가장자리를 부드럽게. MSAA를 켠 상태에서 Alpha Antialiasing → Alpha Edge Clip(또는 Blend)으로 설정하면, 실제 투명도 없이도 잎 가장자리가 부드러워집니다.
- 카드 모양으로 오버드로를 줄이세요. 둥근 잔가지를 네모난 카드로 감싸면 픽셀이 많이 낭비됩니다. 잎 뭉치 모양대로 잘라 낸 카드는 셰이딩하는 보이지 않는 픽셀이 훨씬 적습니다. 뷰포트의 Display Overdraw 모드로 확인해 보세요. 수관이 새빨갛다면 층이 너무 많은 겁니다.
- 아무도 보지 않는 건 지우세요. 수관 깊숙한 곳의 잎 뭉치는 밖에서 보이지 않습니다. 먼 LOD에서는 이것부터 없애야 합니다.
5단계. 그림자: 숨어 있는 두 번째 숲
그림자를 드리우는 나무는 모두 섀도 맵을 위해 한 번 더 렌더링됩니다. 세 가지 설정이 절감 효과의 대부분을 가져옵니다.
- 그림자 거리를 제한하세요.
DirectionalLight3D→ Directional Shadow → Max Distance. 100~200 m 너머에서는 나무 한 그루 한 그루의 그림자가 없어져도 알아채는 플레이어가 거의 없습니다. - 먼 LOD의 그림자를 끄세요. LOD2와 임포스터에서 Cast Shadow → Off로 설정합니다(MultiMesh라면 해당 레벨의
MultiMeshInstance3D에서). - 저사양 하드웨어에서는 캐스케이드를 줄이세요. 캐스케이드를 네 개에서 두 개로 줄여도 눈에 띄는 차이는 없는 경우가 많고, 절감 효과는 확실합니다.
6단계. 오클루전 컬링과 바람
오클루전 컬링은 언덕, 절벽, 건물, 지형 자체처럼 큰 오브젝트가 숲을 가릴 때 도움이 됩니다. Project Settings → Rendering → Occlusion Culling에서 켜고, OccluderInstance3D를 추가한 뒤 베이크하세요. 나무 자체는 구멍투성이라 오클루더로는 형편없으니, 그 일은 지형에 맡기세요. 가장 큰 이득을 보는 건 청크 단위 MultiMesh입니다. 가려진 청크 전체를 건너뛸 수 있으니까요.
바람은 버텍스 셰이더에 두고, 메시에 미리 계산해 둔 가중치를 읽어야 합니다. 줄기는 거의 움직이지 않고, 굵은 가지는 천천히 흔들리며, 잎 끝은 빠르게 파르르 떨립니다. 이걸 셰이더에서 처음부터 계산하면 비싸지만, 버텍스당 숫자 두 개를 읽는 건 거의 공짜입니다.
7단계. 타깃 하드웨어에서 측정하기
절대 감으로 최적화하지 마세요. Godot은 필요한 모든 걸 제공합니다.
- Debugger → Monitors: 프레임당 드로우 콜, 오브젝트 수, 프리미티브(폴리곤) 수.
- Debugger → Visual Profiler: CPU와 GPU가 프레임의 각 부분에 얼마나 시간을 쓰는지.
- Viewport → Display Overdraw / Wireframe: 픽셀과 폴리곤이 어디에 쓰이는지.
변경할 때마다 전후로 측정하세요. 개발용 PC만이 아니라, 지원하려는 가장 약한 기기에서요.
체크리스트
- 모든 나무에 거리별 폴리곤 예산이 있고, 각 레벨은 3~4배 가볍다.
- 나무는 자체 LOD와 Visibility Range를 쓰고, 자동 메시 LOD는 바위와 소품에만 쓴다.
- 수천 그루의 나무를 LOD0 거리 정도 크기의 청크 단위 MultiMesh로 나눴다.
- 잎은 Alpha Scissor와 Alpha to Coverage를 쓰고, 카드는 잎 뭉치 모양을 따른다.
- 그림자 거리를 제한했고, 먼 LOD와 임포스터는 그림자를 드리우지 않는다.
- 지형과 큰 오브젝트로 오클루전 컬링을 베이크했다.
- 바람은 버텍스별 가중치를 읽는 가벼운 버텍스 셰이더다.
- 드로우 콜과 프레임 시간을 타깃 기기에서 측정했다.
FoliMesh는 어디에 쓰이나
저희는 저희 대규모 게임을 위해 FoliMesh를 만들었기 때문에, FoliMesh가 생성하는 나무는 처음부터 이 규칙을 따릅니다. Godot 패키지는 프로젝트에 그냥 넣으면 되는 폴더로, 폴리곤 예산에 맞춰 생성된 LOD0, LOD1, LOD2…가 담긴 GLB, 레벨마다 Visibility Range를 스크립트로 설정하는 바로 쓸 수 있는 .tscn 씬, 바람 셰이더(줄기 휨과 가지 흔들림은 메시의 두 번째 UV 채널에 저장), 그리고 콜리전용으로 줄기를 감싸는 **캡슐이 달린 StaticBody3D**가 들어 있습니다. 노멀 맵은 이미 Godot이 기대하는 OpenGL 형식입니다.
임포트 전에 무료 브라우저 도구로 어떤 모델이든 확인할 수 있습니다. LOD 뷰어, 폴리곤 카운터, glTF 검증기를 써 보세요. 나무 제작 도구 전반이 궁금하다면 3D 나무 생성기 비교를 참고하세요.
FAQ
Godot 4는 LOD를 자동으로 만들어 주나요? 네. 임포트한 glTF, .blend, Collada, FBX 씬에 대해 임포트 시 메시 LOD를 생성합니다. 연속된 표면에서 가장 잘 작동하며, 나무에는 직접 만들거나 생성한 LOD를 Visibility Range와 함께 쓰는 편이 결과를 예측하기 쉽습니다.
MultiMesh에서 LOD가 왜 안 되나요?
Godot은 MultiMeshInstance3D 전체에 대해 하나의 LOD 레벨을 선택합니다. 인스턴스를 청크로 나누고, 청크마다 자체 노드와 Visibility Range를 주세요.
Godot은 나무를 몇 그루까지 렌더링할 수 있나요? 엔진이 아니라 나무 한 그루당 예산에 달려 있습니다. 청크 단위 MultiMesh, 가벼운 원거리 LOD, 임포스터를 쓰면 데스크톱 하드웨어에서 화면에 수만 그루를 띄우는 것도 현실적입니다. 타깃 기기에서 측정해 보세요.
잎에는 Alpha Scissor와 Alpha Hash 중 무엇을 써야 하나요? 보통은 Alpha Scissor와 Alpha to Coverage 조합을 씁니다. Alpha Hash는 MSAA 없이도 가장자리가 부드럽지만 노이즈가 보이고, TAA와 함께 쓸 때 가장 잘 작동합니다.

