모바일 및 크로스 플랫폼 개발 시 빌드 타임과 메모리를 낭비하는 셰이더 변형을 효과적으로 스트리핑하고, GPU 인스턴싱을 통해 드로우 콜을 획기적으로 줄이는 방법을 심층 분석합니다.
도입 및 개요
유니버설 렌더 파이프라인(URP)과 고해상도 렌더 파이프라인(HDRP)은 시각적 품질을 크게 향상시키지만, 프로젝트의 복잡도가 증가함에 따라 '셰이더 변형(Shader Variants)'이라는 거대한 함정에 빠지기 쉽습니다. 셰이더 변형은 셰이더 코드 내에 정의된 #pragma multi_compile 또는 #pragma shader_feature 키워드들의 모든 가능한 조합을 의미합니다. 예를 들어, 빛의 종류(Directional, Point, Spot), 그림자 사용 여부, 라이트맵 유무, 포그(Fog) 설정 등 다양한 렌더링 옵션이 켜지거나 꺼짐에 따라 유니티는 내부적으로 수많은 버전의 셰이더 프로그램을 생성합니다. 문제는 이러한 조합이 곱연산으로 폭발적으로 증가하여 수만, 심지어 수백만 개의 변형을 만들어낼 수 있다는 점입니다. 불필요한 셰이더 변형이 빌드에 포함되면 빌드 타임이 수 시간으로 늘어날 뿐만 아니라, 런타임 시 셰이더 로딩 시간 지연과 방대한 메모리 점유로 인해 치명적인 성능 저하를 초래합니다.
이를 해결하기 위한 첫 번째 실무 단계는 '셰이더 스트리핑(Shader Stripping)'입니다. 유니티는 기본적으로 프로젝트 설정(Graphics Settings)과 활성화된 씬을 기반으로 사용되지 않는 변형을 제거하려 시도하지만, 동적 로딩이나 커스텀 설정이 개입되면 이것만으로는 부족합니다. 진정한 최적화를 위해서는 IPreprocessShaders 인터페이스를 상속받은 에디터 스크립트를 작성하여 빌드 프로세스에 직접 개입해야 합니다. OnProcessShader 콜백 함수 내에서 현재 렌더링 파이프라인이나 게임의 타겟 플랫폼에서 절대 사용되지 않을 키워드(예: 모바일 빌드에서의 특정 퀄리티 관련 키워드, 사용하지 않는 포인트 라이트 그림자 등)를 식별하고, 해당 키워드를 포함하는 셰이더 컴파일러 데이터를 리스트에서 강제로 삭제(RemoveAt)하는 스크립트를 작성해야 합니다. 이 과정을 통해 셰이더 컴파일 시간을 수십 분에서 몇 분 단위로 단축시키고, 최종 앱 용량을 수십 메가바이트 이상 절감할 수 있습니다.
두 번째 핵심 최적화는 렌더링 부하 자체를 줄이는 'GPU 인스턴싱(GPU Instancing)'의 극대화입니다. 숲을 구성하는 수천 그루의 나무나 전장에 흩어진 수많은 돌멩이들을 렌더링할 때, 유니티는 기본적으로 각 오브젝트마다 별도의 드로우 콜(Draw Call, CPU가 GPU에게 그리기 명령을 내리는 횟수)을 발생시킵니다. 모바일 기기에서는 드로우 콜이 200~300번만 넘어가도 병목이 발생하기 시작합니다. GPU 인스턴싱은 동일한 메시(Mesh)와 매터리얼(Material)을 공유하는 여러 오브젝트를 단 한 번의 드로우 콜로 렌더링할 수 있게 해주는 마법 같은 기술입니다. 셰이더 스크립트에 #pragma multi_compile_instancing을 선언하고, 인스턴스마다 달라지는 고유 데이터(예: 색상이나 특정 오프셋 값)를 UNITY_INSTANCING_BUFFER 내에 정의함으로써, GPU가 한 번의 명령으로 수백 개의 각기 다른 위치와 속성을 가진 오브젝트를 순식간에 화면에 그려내도록 만들 수 있습니다.
그러나 GPU 인스턴싱을 적용할 때 주의해야 할 실무적 함정이 있습니다. 바로 머티리얼 인스턴스의 생성입니다. 스크립트에서 GetComponent<Renderer>().material.color = Color.red; 와 같이 머티리얼 속성에 직접 접근하여 값을 변경하면, 유니티는 내부적으로 기존 머티리얼을 복제하여 새로운 머티리얼 인스턴스를 생성해버립니다. 이렇게 되면 동일한 머티리얼을 공유한다는 인스턴싱의 전제 조건이 깨져 드로우 콜이 분리되고, 최적화 노력이 물거품이 됩니다. 이를 방지하기 위해서는 반드시 MaterialPropertyBlock 객체를 활용해야 합니다. Renderer.SetPropertyBlock() 메서드를 통해 개별 오브젝트의 속성을 전달하면, 공유 머티리얼을 깨뜨리지 않고 인스턴스별 커스텀 데이터를 성공적으로 GPU에 전달할 수 있어 완벽한 GPU 인스턴싱 상태를 유지할 수 있습니다. 셰이더 스트리핑과 MaterialPropertyBlock의 결합은 고품질 그래픽스와 높은 프레임 레이트라는 두 마리 토끼를 잡게 해줍니다.
using UnityEditor.Build;
using UnityEditor.Rendering;
using System.Collections.Generic;
using UnityEngine;
class ShaderStripper : IPreprocessShaders
{
public int callbackOrder { get { return 0; } }
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList data)
{
for (int i = data.Count - 1; i >= 0; --i)
{
if (data[i].shaderKeywordSet.IsEnabled(new UnityEngine.Rendering.ShaderKeyword("_ADDITIONAL_LIGHT_SHADOWS")))
{
data.RemoveAt(i);
}
}
}
}