[개발 일기] 프로젝트 최적화하기(2)
어제에 이어서 작성해보겠습니다.
우선 어제의 가장 큰 문제점은 카메라가 나무 가림 처리 과정에서 매 프레임 메모리 할당이 발생한다는 점이었습니다.
기존 CameraOcclusionHandler는 플레이어와 카메라 사이에 나무가 있는지 검사하고, 나무가 시야를 가리면 잎을 숨기고 줄기를 투명하게 만드는 역할을 했습니다. 시야에서 벗어난 나무는 다시 원래 상태로 복원했습니다.
기능 자체는 정상적으로 동작했지만, 이 과정에서 매 프레임 충돌 결과 배열과 임시 컬렉션을 생성하고 있었습니다. 또한 나무의 잎과 줄기를 찾기 위해 하위 오브젝트를 반복적으로 탐색했습니다.
이를 최적화하기 위해 반복 작업과 메모리 할당을 줄여봤습니다.
나무 참조 미리 지정하기
기존에는 충돌한 나무에서 Transform.Find()로 잎과 줄기를 찾고 GetComponentInChildren()로 줄기의 Renderer를 가져왔습니다.
하지만 나무는 모두 프리팹으로 생성되므로 매번 내부 구조를 탐색하는 방법이 아닌, 나무에 OcclusionTarget 컴포넌트를 추가하고 숨길 잎과 재질을 변경할 줄기를 Inspector에서 미리 지정하도록 변경했습니다.
코드
using UnityEngine;
public class OcclusionTarget : MonoBehaviour
{
[SerializeField] private GameObject[] canopies;
[SerializeField] private Renderer[] trunks;
private bool isOccluded;
public void SetOccluded(bool occluded, Material material)
{
if (isOccluded == occluded) return;
isOccluded = occluded;
foreach (GameObject canopy in canopies)
{
if (canopy != null)
canopy.SetActive(!occluded);
}
foreach (Renderer trunk in trunks)
{
if (trunk != null)
trunk.material = material;
}
}
}
즉 CameraOcclusionHandler는 시야를 가리는 나무를 검사하고 상태 변경을 요청, OcclusionTarget은 연결된 잎과 줄기의 표시 상태를 변경하도록 구현했습니다.
충돌 결과 배열 재사용하기
기존의 SphereCastAll은 호출할 때마다 충돌 결과 배열을 반환했습니다. 이를 미리 준비한 배열에 결과를 저장하는 SphereCastNonAlloc으로 변경했습니다.
private RaycastHit[] hitBuffer = new RaycastHit[32]
배열은 한번 만들어두고 매 프레임 반환된 충돌 개수인 hitCount만큼만 확인하도록 했습니다.
현재 버퍼의 크기는 일단 32로 설정해줬습니다. 원래는 32개를 넘어가면 배열을 확장해서 다시 검사하는 로직이 있어야하지만 제 게임에 32개 나무가 한번에 동시에 보여지는 일은 없을 것 같아서 일단 32로 설정했습니다.
이전 대상과 현재 대상 목록 재사용하기
가림 처리를 위해 이번 프레임에 감지된 나무뿐 아니라 이전 프레임에 가렸던 나무도 알아야합니다. 이전에는 가렸지만 지금은 감지되지 않는 나무를 다시 보여지게 처리해야하기 때문입니다.
이를 위해 두 가지 List를 관리했습니다.
- previousTargets: 이전 프레임의 가림 대상
- currentTargets: 현재 프레임의 가림 대상
현재 목록은 Clear()로 비운 뒤 다시 채웠습니다. Clear()로 리스트의 내부 저장 공간은 유지하면서 매번 새 List를 만드는 작업을 피했습니다.
코드
using System.Collections.Generic;
using UnityEngine;
public class CameraOcclusionHandler : MonoBehaviour
{
[SerializeField]
private Transform player;
[SerializeField]
private LayerMask canopyLayer;
[SerializeField]
private Material originalMaterial;
[SerializeField]
private Material transparentMaterial;
[SerializeField]
private float sphereRadius = 0.5f;
private Camera cam;
private RaycastHit[] hitBuffer = new RaycastHit[32];
private List<OcclusionTarget> previousTargets = new();
private List<OcclusionTarget> currentTargets = new();
private void Awake()
{
cam = Camera.main;
}
private void Update()
{
Vector3 direction = cam.transform.position - player.position;
float distance = direction.magnitude;
currentTargets.Clear();
int hitCount = Physics.SphereCastNonAlloc(
player.position,
sphereRadius,
direction.normalized,
hitBuffer,
distance,
canopyLayer
);
for (int i = 0; i < hitCount; i++)
{
OcclusionTarget target = hitBuffer[i].collider.GetComponentInParent<OcclusionTarget>();
if (target != null && !currentTargets.Contains(target))
currentTargets.Add(target);
}
foreach (OcclusionTarget previousTarget in previousTargets)
{
if (previousTarget != null && !currentTargets.Contains(previousTarget))
previousTarget.SetOccluded(false, originalMaterial);
}
foreach (OcclusionTarget currentTarget in currentTargets)
{
if (currentTarget != null && !previousTargets.Contains(currentTarget))
currentTarget.SetOccluded(true, transparentMaterial);
}
List<OcclusionTarget> temp = previousTargets;
previousTargets = currentTargets;
currentTargets = temp;
}
private void OnDisable()
{
foreach (OcclusionTarget target in previousTargets)
{
if (target != null)
target.SetOccluded(false, originalMaterial);
}
previousTargets.Clear();
currentTargets.Clear();
}
}
결과 확인
수정 전

수정 후

수정 전 후를 비교하기 위해 Android로 빌드하여 Profiler로 검사를 해봤습니다. 우선 수정 전에는 선택한 프레임에서 CameraOcclusionHanlder.Update에 8.9KB의 GC Alloc이 발생했습니다. 수정 후에는 0KB로 표시되었습니다.
실행 시간도 평균적으로 수정 전에는 0.20ms에서 수정 후 확인한 프레임에서는 0.05ms로 나타났습니다.
이번 작업에서 확인한 핵심 성과는 카메라 가림 처리의 반복적인 메모리 할당을 줄였다는 점입니다.
느낀점
이번에 코드를 직접 작성해보면서(물론 AI의 도움을 조금 받긴 했습니다..) 확실히 실력이 많이 부족하다는 느낌을 받았습니다. 그리고 이전 코드보다 훨씬 간단하게 구현한 것 같아서, AI를 너무 믿으면 안되겠다고 크게 깨닳았습니다.
요즘 공고를 보는데 AI를 어떻게 활용하는지 보는 회사가 많은 것 같습니다. 솔직히 저는 AI를 잘 활용하진 못하는 것 같습니다. 뭔가 다른 이야기들 들어보면 정말 AI가 다 해주는것 같은데, 제 AI만 바보같고 뭔가를 잘 못해주는 느낌이 있습니다. AI도 주인을 닮아서 그런가.. 아무튼 오랜만에 코드 직접 작성하면서 고민해보니까 재밌긴 하네요. 너무 AI한테만 맡기지 말고 실력을 키우자를 결심하게 된 최적화 작업이었던 것 같습니다.



