[Godot] Shader Precompilation

progman·2023년 2월 4일
0

Shader Hiccups

오늘날 그래픽 디바이스들은 셰이더에 대해 Lazy Compilation을 수행한다. 즉, 셰이더를 미리 컴파일하지 않고 필요할 때(On-Demand) 컴파일한다. 모든 셰이더에 대해 컴파일하면 시간이 오래 걸리니 당장 필요한 셰이더를 그때그때 런타임 빌드를 하겠다는 것이다.

이 방식은 부작용을 낳았는데, 바로 셰이더 힉업(Shader Hiccups)이다. 당장 빌드할 필요가 없던 셰이더가 뷰포트에 잡히는 순간 해당 셰이더에 대해 컴파일을 수행하는데, 이 때 잠깐의 프리징 현상이 일어난다. 말 그대로 셰이더가 딸꾹질을 하는 것이다.

셰이더 힉업현상이 잦을 수록 게이머는 불쾌함을 느낄 것이다. 게임 제작 시 해결해야 할 이슈 중 하나이다. 어떻게 해결해야 할까?

해결법

강제 셰이더 컴파일

전통적인 workaround로는 화면에 셰이더를 직접 띄워 인위적으로 셰이더를 컴파일하게 하는 방법이다. 간단한 쿼드메쉬에 빌드하고 싶은 셰이더를 발라서 카메라 앞에 보이도록 세운 뒤, 어느정도 랜더링할 시간을 준 후 제거하면 된다.

유니티

유니티에서는 셰이더 힉업을 방지해줄 기능을 이미 제공하고 있다. 단, 이것을 사용해도 여전히 셰이더 힉업 현상이 있다고도 한다.

Shader Cache

빌드된 셰이더를 저장한 뒤 다음에 재사용하면 프리징 없이 부드러운 게임플레이가 가능할 것이다. Vulkan은 셰이더 캐시를 지원하고 있으며, OpenGL은 glProgramBinary를 통해 셰이더 프로그램 재사용이 가능하다.

고도는?

그래서 고도에서는 셰이더 힉업을 어떻게 해결해야 할까? 고도는 유니티처럼 자체적으로 셰이더 힉업을 해결하는 기능을 제공해주지 않는다. 셰이더 캐시는 Vulkan의 경우 4.0부터 지원하며, 3.x는 OpenGL을 사용하지만, glProgramBinary는 4.1 core부터 지원한다. 이렇다보니 고도에서 셰이더 힉업에 대한 해결법은 딱 명료하게 떨어지지 않고 케이스마다 다르다.

4.x: Vulkan

Vulkan에서 이미 셰이더 캐시를 이용하는 만큼 4.0 이상의 버전을 쓰는 개발자들은 벌칸을 이용하면 된다.

3.x

3.5~: Async Shader Compilation + Cache

3.5 이상 버전부터 고도는 비동기 셰이더 컴필레이션 및 캐시 기능을 제공하기 시작했다. 이를 통해 미리 컴파일된 셰이더를 이용하여 셰이더 힉업현상을 제거할 수 있다. 그러나 첫 실행일 경우 미리 컴파일된 셰이더가 없는 상태이니 결국 첫 실행에선 셰이더 힉업현상을 피할 수 없다는 문제가 있다.

강제 셰이더 컴파일

결국 내가 내린 3.x의 궁극적인 해결방법은 전통의 workaround를 따르는 것이다. 셰이더 힉업을 없앨 수 있는 확실한 방법이기도 하고, 캐시 기능까지 쓰게 되면 결국 남는 건 첫 실행 컴파일 시간 밖에 없다.

카메라가 주어지면 그 카메라의 앞에 원하는 셰이더를 바른 메쉬들을 배치한다. 일반 메쉬의 머터리얼은 QuadMesh를 생성한 뒤 해당 머터리얼을 발라주면 될 것이고, 파티클은 파티클을 생성한 뒤 프로세스 머터리얼 및 패스메쉬와 패스메쉬에 바를 머터리얼을 설정해주면 된다. 충분히 랜더링할 시간을 준 뒤 생성한 오브젝트들을 제거하면 작업은 끝난다.

아래는 셰이더를 미리 컴파일해줄 예시코드이다. 아래 코드를 그대로 사용해도 좋고, 아니면 분석한 뒤 본인의 프로젝트에 맞게 수정 및 개조해도 된다.

# ShaderPrecompiler.gd

extends Resource
class_name ShaderPrecompiler

const DESTROY_COUNT: int = 5

export (PoolStringArray) var material_paths
export (PoolStringArray) var particles_paths
export (PoolStringArray) var shader_paths

signal compile_completed

func compile(target_camera: Camera) -> void:
	var shader_precompiler: Spatial = Spatial.new()
	target_camera.call_deferred("add_child", shader_precompiler)
	shader_precompiler.set_translation(Vector3(0, 0, -1))

	for node in _generate_all():
		shader_precompiler.call_deferred("add_child", node)

	var count = DESTROY_COUNT
	while count > 0:
		yield(target_camera.get_tree(), "idle_frame")
		count -= 1
	shader_precompiler.queue_free()
	emit_signal("compile_completed")

func _generate_all() -> Array:
	var generated_nodes: Array = Array()

	for material_path in material_paths:
		generated_nodes.append(_create_mesh_with_material(material_path))
	
	for particles_path in particles_paths:
		generated_nodes.append(_create_particles(particles_path))

	for shader_path in shader_paths:
		generated_nodes.append(_create_mesh_with_shader(shader_path))

	return generated_nodes

func _create_mesh_with_material(material_path: String) -> MeshInstance:
	var mesh: QuadMesh = QuadMesh.new()
	mesh.set_material(load(material_path))

	var mesh_instance: MeshInstance = MeshInstance.new()
	mesh_instance.set_mesh(mesh)
	mesh_instance.set_rotation_degrees(Vector3(90, 0, 0))
	mesh_instance.set_name(material_path.get_file().split(".")[0])

	return mesh_instance

func _create_mesh_with_shader(shader_path: String) -> MeshInstance:
	var material: ShaderMaterial = ShaderMaterial.new()
	material.shader = load(shader_path)

	var mesh: QuadMesh = QuadMesh.new()
	mesh.set_material(material)

	var mesh_instance: MeshInstance = MeshInstance.new()
	mesh_instance.set_mesh(mesh)
	mesh_instance.set_rotation_degrees(Vector3(90, 0, 0))
	mesh_instance.set_name(shader_path.get_file().split(".")[0])

	return mesh_instance

func _create_particles(particles_path: String) -> Particles:
	var particles_instance = load(particles_path).instance()
	particles_instance.set_emitting(true)
	particles_instance.set_one_shot(false)
	return particles_instance
profile
그저 개발중인 사람입니다.

0개의 댓글